diff --git a/.github/scripts/verify-gradle-wrapper.sh b/.github/scripts/verify-gradle-wrapper.sh index d5c2f6f7..3a1ee42a 100755 --- a/.github/scripts/verify-gradle-wrapper.sh +++ b/.github/scripts/verify-gradle-wrapper.sh @@ -36,6 +36,14 @@ readonly EXPECTED_WORKFLOW_LOCK=( '4e4ccfa267ecd63b9369803d49f2dbdb2fa899517ad4cf23ab11d29104557a91 .github/workflows/notification-platform.yml' '64245586cd5936f1a5647b57f2cd9acd316f96fd75f713b1890decb812e7d5fe .github/workflows/object-storage-qualification.yml' 'cbc104ea486c746229895e804e3be7716e056a02cce0588c537bce9f442f8b38 .github/workflows/redis-sdk-topology.yml' + '89fb84532d542f7951e11cf2925425ea84b7ef9cc22f4587f1d2cfd99c481f5f .github/workflows/web-advanced-nightly.yml' + 'a3d01b73831f1f77a09edfe883e32cd63c8dc8c79b022faf7fec7bdd08c6e4db .github/workflows/web-advanced-release.yml' + '4198ce8215097ae9342167c4985455bbe6e56956a3be05bee33428381cad638d .github/workflows/web-nightly.yml' + 'b07b92c43e94f674fe6c851603dd27bbed72894274f031b95c3d6e2b256650bd .github/workflows/web-pr.yml' + 'a82f3eacee01165cf9c0767657a584a2524d7bb751f1d567a696241ea44cb3b6 .github/workflows/web-release.yml' + 'f37b2b2598687679a3fb0ae9ea2b50cd5d84a64de7e5852f38a3e5b93bf76e4d .github/workflows/websocket-advanced-nightly.yml' + '5643fe9c9d27d9e6f5ac30a731e77a962b68bed2961566e2e64cdb3991ef2350 .github/workflows/websocket-pr.yml' + 'c9fabc17fe755f9f0ee54007e48357fec9493a89ecf67fa2878f4dbc23478f30 .github/workflows/websocket-release.yml' ) readonly EXPECTED_WRAPPER_PROPERTIES=( 'distributionBase=GRADLE_USER_HOME' diff --git a/.github/workflows/web-advanced-nightly.yml b/.github/workflows/web-advanced-nightly.yml new file mode 100644 index 00000000..68d3f828 --- /dev/null +++ b/.github/workflows/web-advanced-nightly.yml @@ -0,0 +1,66 @@ +name: web-advanced-nightly + +# Every web Advanced capability is off in production unless a deployment names it, which means none +# of them is exercised by the ordinary PR gate. That is exactly why they need their own nightly: a +# capability nobody runs is a capability nobody notices breaking, and the first person to find out +# is whoever enabled it. +# +# The lane is tagged rather than module-scoped because Advanced lives in the same leaf as Stable. + +on: + workflow_dispatch: + schedule: + # 03:30 UTC, after web-nightly. They contend for the same machine when streaming holds + # connections, and a load lane that shares a runner measures the runner. + - cron: '30 3 * * *' + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + +jobs: + web-advanced-capabilities: + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 + - name: Validate Gradle wrapper + id: gradle-wrapper-validation + uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 + - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1 + with: + distribution: temurin + java-version: "21.0.11+10" + cache: gradle + cache-dependency-path: | + src/**/*.gradle + src/**/gradle-wrapper.properties + src/**/gradle.lockfile + - name: Run the Advanced capability lane + working-directory: src + run: >- + ./gradlew + :adapter:inbound:web:webAdvancedTest + --no-daemon + --stacktrace + - name: Prove Stable behaviour is unchanged with every flag off + # The rollback assertion, run as its own step so a failure names itself. Two of the twelve + # capabilities change requests that do not use them, and this is what catches a third + # acquiring that property by accident. + working-directory: src + run: >- + ./gradlew + :adapter:inbound:web:test + --tests '*WebAdvancedReleaseTest*' + --no-daemon + --stacktrace + - name: Publish the test reports + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4.6.2 + with: + name: web-advanced-nightly-reports + path: src/adapter/inbound/web/build/reports/tests/ + if-no-files-found: warn diff --git a/.github/workflows/web-advanced-release.yml b/.github/workflows/web-advanced-release.yml new file mode 100644 index 00000000..614bb844 --- /dev/null +++ b/.github/workflows/web-advanced-release.yml @@ -0,0 +1,75 @@ +name: web-advanced-release + +# Promotion evidence for the web Advanced capabilities. +# +# It depends on the Stable gate rather than replacing it: the condition every Advanced capability +# must satisfy is that Stable behaviour is unchanged with the feature off, and that is only +# meaningful against a Stable suite that passed in the same run. + +on: + workflow_dispatch: + push: + tags: + - 'v*' + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +jobs: + web-advanced-promotion-evidence: + runs-on: ubuntu-latest + timeout-minutes: 90 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 + - name: Validate Gradle wrapper + id: gradle-wrapper-validation + uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 + - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1 + with: + distribution: temurin + java-version: "21.0.11+10" + cache: gradle + cache-dependency-path: | + src/**/*.gradle + src/**/gradle-wrapper.properties + src/**/gradle.lockfile + - name: Establish the Stable baseline + working-directory: src + run: >- + ./gradlew + :adapter:inbound:web:test + :adapter:inbound:web:webJettyCompatTest + :adapter:inbound:web:webFluxContractTest + --no-daemon + --stacktrace + - name: Run the Advanced capability lane + working-directory: src + run: >- + ./gradlew + :adapter:inbound:web:webAdvancedTest + --no-daemon + --stacktrace + - name: Verify the architecture boundary Stable depends on + # WEB-ARCH-ADV. A feature flag decides whether an Advanced bean is created; it does nothing + # about a Stable class that imports an Advanced type, and one such edge makes the Stable + # platform unbuildable without the Advanced code. + working-directory: src + run: >- + ./gradlew + :adapter:inbound:web:test + --tests '*WebArchitectureRulesTest*' + --tests '*WebModuleBoundaryTest*' + verifyCleanArchitectureDependencies + --no-daemon + --stacktrace + - name: Publish the promotion evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4.6.2 + with: + name: web-advanced-release-evidence + path: src/adapter/inbound/web/build/reports/tests/ + if-no-files-found: warn diff --git a/.github/workflows/web-nightly.yml b/.github/workflows/web-nightly.yml new file mode 100644 index 00000000..947c52e0 --- /dev/null +++ b/.github/workflows/web-nightly.yml @@ -0,0 +1,55 @@ +name: web-nightly + +# The gates that are too slow for a pull request and too important to run only at release. Load, +# abuse and graceful shutdown all need a machine that is not simultaneously compiling something +# else, and all three measure behaviour that degrades gradually rather than breaking outright — +# which is exactly the kind of regression a per-PR gate never catches and a nightly one does. + +on: + workflow_dispatch: + schedule: + # 03:00 UTC. Late enough that the day's merges are in, early enough that a failure is triaged + # before the next working day starts. + - cron: '0 3 * * *' + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + +jobs: + web-load-abuse-and-shutdown: + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 + - name: Validate Gradle wrapper + id: gradle-wrapper-validation + uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 + - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1 + with: + distribution: temurin + java-version: "21.0.11+10" + cache: gradle + cache-dependency-path: | + src/**/*.gradle + src/**/gradle-wrapper.properties + src/**/gradle.lockfile + - name: Run the load, abuse and shutdown lanes on every container + working-directory: src + run: >- + ./gradlew + :adapter:inbound:web:test + :adapter:inbound:web:webJettyCompatTest + :adapter:inbound:web:webFluxContractTest + --no-daemon + --stacktrace + - name: Publish the test reports + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4.6.2 + with: + name: web-nightly-reports + path: src/adapter/inbound/web/build/reports/tests/ + if-no-files-found: warn diff --git a/.github/workflows/web-pr.yml b/.github/workflows/web-pr.yml new file mode 100644 index 00000000..4e22ba5e --- /dev/null +++ b/.github/workflows/web-pr.yml @@ -0,0 +1,114 @@ +name: web-pr + +# Every Stable claim the web platform makes is backed by a job here. The lanes are split by what +# they need rather than by what they test: the cross-container matrix needs three source sets, the +# proxy contract needs Docker, and the load gate needs a machine that is not also compiling. A +# single job running everything would attribute every failure to "the web tests". + +on: + workflow_dispatch: + pull_request: + paths: + - 'src/adapter/inbound/web/**' + - 'src/application-core/src/**/operation/**' + - 'src/application-core/src/**/idempotency/**' + - 'src/adapter/outbound/persistence-jpa/src/**/operation/**' + - 'docs/web/**' + - '.github/workflows/web-pr.yml' + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + web-unit-and-architecture: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 + - name: Validate Gradle wrapper + id: gradle-wrapper-validation + uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 + - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1 + with: + distribution: temurin + java-version: "21.0.11+10" + cache: gradle + cache-dependency-path: | + src/**/*.gradle + src/**/gradle-wrapper.properties + src/**/gradle.lockfile + - name: Run the web unit, module-boundary and architecture suites + working-directory: src + run: >- + ./gradlew + :adapter:inbound:web:test + :application-core:test + verifyCleanArchitectureDependencies + --no-daemon + --stacktrace + + # The parity gate depends on all three recording lanes and fails when one is missing, so it runs + # them itself rather than trusting a previous job to have left the recordings behind. + web-cross-stack-parity: + runs-on: ubuntu-latest + timeout-minutes: 40 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 + - name: Validate Gradle wrapper + id: gradle-wrapper-validation + uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 + - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1 + with: + distribution: temurin + java-version: "21.0.11+10" + cache: gradle + cache-dependency-path: | + src/**/*.gradle + src/**/gradle-wrapper.properties + src/**/gradle.lockfile + - name: Compare the wire contract across Tomcat, Jetty and Reactor Netty + working-directory: src + run: >- + ./gradlew + :adapter:inbound:web:webCrossStackParityTest + --no-daemon + --stacktrace + - name: Publish the parity recordings + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4.6.2 + with: + name: web-contract-parity + path: src/adapter/inbound/web/build/web-contract-parity/ + if-no-files-found: error + + # Docker-gated, and the lane fails rather than skipping when the runtime is missing. A proxy + # contract that quietly passes without a proxy has been certifying nothing since whenever the + # container runtime last broke. + web-nginx-proxy-contract: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 + - name: Validate Gradle wrapper + id: gradle-wrapper-validation + uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 + - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1 + with: + distribution: temurin + java-version: "21.0.11+10" + cache: gradle + cache-dependency-path: | + src/**/*.gradle + src/**/gradle-wrapper.properties + src/**/gradle.lockfile + - name: Run the proxy, prefix and spoofing contract behind a real Nginx + working-directory: src + run: >- + ./gradlew + :adapter:inbound:web:webNginxProxyTest + --no-daemon + --stacktrace diff --git a/.github/workflows/web-release.yml b/.github/workflows/web-release.yml new file mode 100644 index 00000000..9d9545a5 --- /dev/null +++ b/.github/workflows/web-release.yml @@ -0,0 +1,61 @@ +name: web-release + +# The complete Stable gate. Everything the PR and nightly workflows run, plus the checks whose cost +# is only justified when something is about to ship: the public API surface, the environment key +# registry and the whole architecture verification. +# +# It is one workflow rather than a reference to the others because a release gate that depends on +# another workflow having run is a gate whose result depends on scheduling. + +on: + workflow_dispatch: + push: + tags: + - 'web-v*' + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +jobs: + web-stable-release-gate: + runs-on: ubuntu-latest + timeout-minutes: 90 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 + - name: Validate Gradle wrapper + id: gradle-wrapper-validation + uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 + - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1 + with: + distribution: temurin + java-version: "21.0.11+10" + cache: gradle + cache-dependency-path: | + src/**/*.gradle + src/**/gradle-wrapper.properties + src/**/gradle.lockfile + - name: Run every web lane and the architecture-wide verification + working-directory: src + run: >- + ./gradlew + :adapter:inbound:web:webCrossStackParityTest + :adapter:inbound:web:webNginxProxyTest + verifyCleanArchitectureDependencies + verifyPublicPathSnapshot + verifyEnvKeys + :app-bootstrap:test --tests '*CleanArchitectureTest' + --no-daemon + --stacktrace + - name: Publish the release evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4.6.2 + with: + name: web-release-evidence + path: | + src/adapter/inbound/web/build/web-contract-parity/ + src/adapter/inbound/web/build/reports/tests/ + if-no-files-found: error diff --git a/.github/workflows/websocket-advanced-nightly.yml b/.github/workflows/websocket-advanced-nightly.yml new file mode 100644 index 00000000..55130699 --- /dev/null +++ b/.github/workflows/websocket-advanced-nightly.yml @@ -0,0 +1,64 @@ +name: websocket-advanced-nightly + +# The WebSocket Advanced capabilities are off unless a deployment names them, so nothing a +# production deployment runs exercises them. A capability nobody runs is a capability nobody +# notices breaking, and the first person to find out is whoever enables it. + +on: + workflow_dispatch: + schedule: + # 04:00 UTC, after the web lanes. Streaming and connection work contend for the same runner, + # and a load lane sharing one measures the runner. + - cron: '0 4 * * *' + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + +jobs: + websocket-advanced-capabilities: + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 + - name: Validate Gradle wrapper + id: gradle-wrapper-validation + uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 + - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1 + with: + distribution: temurin + java-version: "21.0.11+10" + cache: gradle + cache-dependency-path: | + src/**/*.gradle + src/**/gradle-wrapper.properties + src/**/gradle.lockfile + - name: Run the Advanced capability lane + working-directory: src + run: >- + ./gradlew + :adapter:inbound:websocket:websocketAdvancedTest + --no-daemon + --stacktrace + - name: Verify the boundary Stable depends on + # WS-ARCH-6. A flag decides whether an Advanced bean is created; it does nothing about a + # Stable class that imports an Advanced type, and one such edge makes Stable unbuildable + # without Advanced. + working-directory: src + run: >- + ./gradlew + :adapter:inbound:websocket:test + --tests '*WebSocketArchitectureRulesTest*' + --tests '*WebSocketModuleBoundaryTest*' + --no-daemon + --stacktrace + - name: Publish the test reports + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4.6.2 + with: + name: websocket-advanced-nightly-reports + path: src/adapter/inbound/websocket/build/reports/tests/ + if-no-files-found: warn diff --git a/.github/workflows/websocket-pr.yml b/.github/workflows/websocket-pr.yml new file mode 100644 index 00000000..37c75d97 --- /dev/null +++ b/.github/workflows/websocket-pr.yml @@ -0,0 +1,98 @@ +name: websocket-pr + +# Every Stable claim the WebSocket platform makes is backed by a job here. The lanes are split by +# what they need: the runtime matrix needs two containers, and the proxy contract needs Docker. + +on: + workflow_dispatch: + pull_request: + paths: + - 'src/adapter/inbound/websocket/**' + - 'docs/websocket/**' + - '.github/workflows/websocket-pr.yml' + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + websocket-unit-and-architecture: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 + - name: Validate Gradle wrapper + id: gradle-wrapper-validation + uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 + - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1 + with: + distribution: temurin + java-version: "21.0.11+10" + cache: gradle + cache-dependency-path: | + src/**/*.gradle + src/**/gradle-wrapper.properties + src/**/gradle.lockfile + - name: Run the websocket unit, boundary and runtime suites + working-directory: src + run: >- + ./gradlew + :adapter:inbound:websocket:test + verifyCleanArchitectureDependencies + --no-daemon + --stacktrace + + websocket-container-matrix: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 + - name: Validate Gradle wrapper + id: gradle-wrapper-validation + uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 + - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1 + with: + distribution: temurin + java-version: "21.0.11+10" + cache: gradle + cache-dependency-path: | + src/**/*.gradle + src/**/gradle-wrapper.properties + src/**/gradle.lockfile + - name: Run the runtime contract on the second servlet container + working-directory: src + run: >- + ./gradlew + :adapter:inbound:websocket:websocketJettyTest + --no-daemon + --stacktrace + + # Docker-gated, and the lane fails rather than skipping. Upgrade handling is the single most + # common WebSocket deployment failure and it is invisible from either side alone. + websocket-nginx-contract: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 + - name: Validate Gradle wrapper + id: gradle-wrapper-validation + uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 + - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1 + with: + distribution: temurin + java-version: "21.0.11+10" + cache: gradle + cache-dependency-path: | + src/**/*.gradle + src/**/gradle-wrapper.properties + src/**/gradle.lockfile + - name: Run the upgrade and forwarded-header contract behind a real Nginx + working-directory: src + run: >- + ./gradlew + :adapter:inbound:websocket:websocketNginxTest + --no-daemon + --stacktrace diff --git a/.github/workflows/websocket-release.yml b/.github/workflows/websocket-release.yml new file mode 100644 index 00000000..783329b0 --- /dev/null +++ b/.github/workflows/websocket-release.yml @@ -0,0 +1,56 @@ +name: websocket-release + +# The complete Stable gate: every lane plus the architecture-wide verification. One workflow rather +# than a reference to the others, because a release gate that depends on another workflow having run +# is a gate whose result depends on scheduling. + +on: + workflow_dispatch: + push: + tags: + - 'websocket-v*' + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +jobs: + websocket-stable-release-gate: + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # actions/checkout@v4.2.2 + - name: Validate Gradle wrapper + id: gradle-wrapper-validation + uses: gradle/actions/wrapper-validation@3f131e8634966bd73d06cc69884922b02e6faf92 # gradle/actions@v6 + - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # actions/setup-java@v4.7.1 + with: + distribution: temurin + java-version: "21.0.11+10" + cache: gradle + cache-dependency-path: | + src/**/*.gradle + src/**/gradle-wrapper.properties + src/**/gradle.lockfile + - name: Run every websocket lane and the architecture-wide verification + working-directory: src + run: >- + ./gradlew + :adapter:inbound:websocket:test + :adapter:inbound:websocket:websocketJettyTest + :adapter:inbound:websocket:websocketNginxTest + :adapter:inbound:websocket:websocketTransportQualificationTest + verifyCleanArchitectureDependencies + :app-bootstrap:test --tests '*CleanArchitectureTest' + --no-daemon + --stacktrace + - name: Publish the release evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # actions/upload-artifact@v4.6.2 + with: + name: websocket-release-evidence + path: src/adapter/inbound/websocket/build/reports/tests/ + if-no-files-found: error diff --git a/CLAUDE.md b/CLAUDE.md index 7dd96594..af443259 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # CLAUDE.md -Repository guidance for the Java 21 + Spring Boot 4.0.0 Clean Architecture template. +Repository guidance for the Java 21 + Spring Boot 4.0.8 Clean Architecture template. ## Prime Directive diff --git a/docs/adr/ADR-WEB-ADV-001-streaming-is-live-delivery.md b/docs/adr/ADR-WEB-ADV-001-streaming-is-live-delivery.md new file mode 100644 index 00000000..b6a59ec6 --- /dev/null +++ b/docs/adr/ADR-WEB-ADV-001-streaming-is-live-delivery.md @@ -0,0 +1,69 @@ +# ADR-WEB-ADV-001: Streaming is live delivery, and the web module stores no history + +- Status: Accepted +- Date: 2026-08-25 +- Scope: `adapter:inbound:web` — `advanced.stream.**` + +## Context + +Advanced Tasks 6–13 add SSE, NDJSON and JSON text sequences, with a `Last-Event-ID` resume path. + +One fact drives every decision here: **after the first byte, the HTTP status is 200 and cannot +change.** A stream that ends because a dependency failed and one that ends because it finished are +identical at the transport layer — both are a closed connection after a 200. So is a stream that was +cut off mid-flight. + +The second fact is that a resume path invites the web module to remember things. It must not: the +messaging platform already owns durable event history, and a second copy would have its own +retention, its own eviction and its own opinion about ordering. + +## Decision + +**Three outcomes, expressed in the stream rather than in the status.** `WebStreamEnvelope` is sealed +over `Item`, `Failure` and `Complete`. A client that sees neither terminal envelope has been cut off, +and that third case is recorded as `ABRUPT_CLOSE` rather than counted as a completion — which is +where a rising rate of mid-stream failures would otherwise hide. + +**Nothing writes a problem document onto a committed response.** `WebStreamTerminationMapper` +branches on whether any byte has been written. Before commit, an RFC 9457 problem with a real +status; after, a terminal record. Attempting both produces a body that is half stream and half JSON, +which no client parses and every proxy caches as a success. + +**Positions are monotonic, and it is enforced.** `WebStreamEvidence.recordDelivered` refuses a +repeated or regressing position. A client deduplicating on position would silently drop the second +item. + +**A slow consumer is disconnected, not buffered.** `WebStreamPolicy.maxBufferedItems` is a hard +bound. Backpressure protects the reactive pipeline; it does not protect the server's heap from a +consumer that reads slowly for an hour. + +**Every stream is in a registry, and shutdown drains it.** A node with a hundred open streams and no +other traffic looks idle by request rate. `WebStreamDrainCoordinator` stops accepting first, asks +clients to reconnect, and only then forces the remainder — because a client whose socket is cut +retries immediately, and if every socket is cut at once, every client retries at once. + +**The web module stores no durable history.** `WebStreamReplaySource` is an interface this module +implements nowhere. An expired cursor raises `ReplayCursorExpiredException` rather than resuming from +the oldest retained position, because that delivers a stream with a hole the client cannot see. + +**The replay-to-live seam is watched.** `GapAndDuplicateGuard` detects both directions. Neither is +visible in either half on its own. + +## Consequences + +- Clients must handle three outcomes. A client that treats a closed connection as completion will be + wrong, and no server change can fix that for it. +- An expired `Last-Event-ID` costs the client a full re-read. That is the honest answer. +- JSON-seq is preferred over NDJSON where truncation matters: its separator comes first, so a parser + resynchronises at the next record. NDJSON's delimiter is the thing that gets truncated away. + +## Alternatives considered + +- **Emit a problem document when a stream fails after commit.** Rejected: the body becomes + unparseable and the 200 is cached. +- **Resume from the oldest retained position when the cursor expires.** Rejected: positions are + contiguous from where the replay started, so nothing in the data says events are missing. +- **Store replay history in the web module.** Rejected: a second source of truth that drifts + invisibly. +- **Unbounded buffering for slow consumers.** Rejected: it moves the client's slowness into the + server's heap. diff --git a/docs/adr/ADR-WEB-ADV-002-virtual-threads-do-not-remove-admission.md b/docs/adr/ADR-WEB-ADV-002-virtual-threads-do-not-remove-admission.md new file mode 100644 index 00000000..12a27e64 --- /dev/null +++ b/docs/adr/ADR-WEB-ADV-002-virtual-threads-do-not-remove-admission.md @@ -0,0 +1,66 @@ +# ADR-WEB-ADV-002: Virtual threads change scheduling, not the concurrency budget + +- Status: Accepted +- Date: 2026-08-25 +- Scope: `adapter:inbound:web` — `advanced.virtualthread`, `advanced.blockingbridge` + +## Context + +Advanced Task 2 offers a virtual-thread executor for MVC; Task 3 offers a bounded blocking bridge +for WebFlux. + +A platform-thread MVC deployment has an implicit concurrency limit — the thread pool — and that +limit is usually what has been protecting the database pool, the outbound HTTP bulkhead and every +downstream service from the full arrival rate. Nobody wrote it down as an admission policy; it was a +side effect of the pool size. + +Switching to virtual threads deletes that limit without deleting anything that depended on it. + +## Decision + +**An explicit admission limit is required when virtual threads are enabled.** +`VirtualThreadProfile` refuses construction without one. Without it the deployment accepts every +arrival, queues all of them on the downstream budgets, and times out work that would have succeeded +had it been refused. The load that used to be shed at the front door is shed at the back, after the +cost of accepting it. + +**The limit bounds concurrent use cases, not threads.** `VirtualThreadAdmissionGuard` is a fair +semaphore, not a pool. Bounding threads would put the waiting back and throw away what virtual +threads bought. Ten thousand virtual threads may exist while a hundred hold permits. + +**The downstream budgets are carried in the profile and stated as unchanged.** The whole point is +that they did not grow. `admissionFitsDownstreamBudgets()` reports when the admission limit exceeds +them, without refusing — a deployment can legitimately admit more than its pool when the work is not +all database-bound, and that should be a choice rather than an accident. + +**Blocking offloads are registered, bounded and timed out.** `boundedElastic()` is available from +anywhere and unbounded in practice, so a controller that calls it has silently opted the whole +application into an unbounded pool. `BlockingBridgeProfile` names the operations permitted to +offload; `BlockingBridgeBudget` bounds the concurrency and refuses a caller that cannot get a slot +in time, because otherwise a slow dependency's callers accumulate until the heap does and the fast +dependencies starve behind them. + +**Pinning is observed, not assumed away.** `VirtualThreadProfile.requiredObservations()` lists what +has to be watched — `jdk.VirtualThreadPinned` above all. A synchronized block held across a blocking +call pins the carrier thread, the carrier pool is bounded by CPU count, and enough pinned carriers is +a deadlock a thread dump does not obviously show. + +## Consequences + +- Enabling virtual threads is a two-part change: the executor and the admission limit. The profile + will not let it be one. +- Refusals rise under load, and that is correct. A request refused in a millisecond is better for + the client than the same request accepted and timed out thirty seconds later behind a full pool. + An operator seeing 503s climb should read them as the limit working. +- `VirtualThreadAdmissionGuard.peakActive()` exists so a load test can assert the limit was applied. + It is invisible from throughput, which is why a load test that only measures throughput would pass + with the guard removed. + +## Alternatives considered + +- **Enable virtual threads and raise the downstream budgets to match.** Rejected: the budgets are + sized to what the dependencies can serve, not to what the web tier can accept. +- **Bound the virtual threads themselves with a fixed-size executor.** Rejected: that is a platform + thread pool with extra steps. +- **Let controllers call `boundedElastic()` directly.** Rejected: every such call site is invisible + until the pool is the thing consuming the heap. diff --git a/docs/adr/ADR-WEB-ADV-003-openapi-32-remains-experimental.md b/docs/adr/ADR-WEB-ADV-003-openapi-32-remains-experimental.md new file mode 100644 index 00000000..052fa3de --- /dev/null +++ b/docs/adr/ADR-WEB-ADV-003-openapi-32-remains-experimental.md @@ -0,0 +1,55 @@ +# ADR-WEB-ADV-003: OpenAPI 3.2 is generated in parallel and stays experimental + +- Status: Accepted +- Date: 2026-08-25 +- Scope: `adapter:inbound:web` — `advanced.openapi` + +## Context + +Advanced Task 17 adds an OpenAPI 3.2 generation lane beside the Stable 3.1.2 snapshot. + +Generating 3.2 is cheap. Adopting it is not, and the two get conflated because the generated +document looks fine. The value of an API description is entirely in what consumes it, and a document +in a version a client generator does not fully understand produces a client that compiles and is +wrong — which is worse than no document at all. + +## Decision + +**3.1.2 remains the release artifact.** `OpenApiVersionLane.STABLE_3_1.releaseArtifact()` is true and +`EXPERIMENTAL_3_2`'s is false. This is a property of the type, not a configuration setting. + +**Generating 3.2 must not change the 3.1 snapshot.** Both are produced from the same model, so a +contributor that mutates it on the way to 3.2 changes the artifact that is actually shipped — +silently, and only when the experimental lane runs. `OpenApi32CompatibilityReport` compares the +snapshot hash before and after and makes a difference a promotion blocker. + +**Four kinds of tool are checked separately.** A parser reports structural errors; a linter applies +style rules and accepts documents a parser rejects; a generator produces client code, and this is +where an unsupported construct surfaces — not as an error but as a method with the wrong signature; +a compile of that generated code is the only step that catches it. "OpenAPI 3.2 works" is not a +statement anybody can make. "This document is read correctly by these four tools at these versions" +is. + +**Promotion requires an accepted ADR regardless of how green the matrix is.** +`promotionBlockers(false)` always contains that blocker. A machine-checkable matrix cannot decide +whether the consumer population is ready. + +**Streaming description differences are reported separately.** They are the substantive difference +between the two versions for this application, and folding them into a pass/fail hides what +changed. + +## Consequences + +- The 3.2 document is published as an artifact of the experimental workflow, never of the release + workflow. +- A client generator that only understands 3.1 is unaffected, which is the point. +- Adopting 3.2 later is a documented decision with a named consumer matrix behind it. + +## Alternatives considered + +- **Switch to 3.2 and keep a 3.1 downgrade.** Rejected: the downgrade is lossy in exactly the + constructs 3.2 was wanted for, so it would ship a description that is wrong for both audiences. +- **Generate only 3.2 and let consumers cope.** Rejected: the failure mode is a generated client + that compiles and misbehaves. +- **Skip the client-compile step in the matrix.** Rejected: it is the only one that catches the + failure the others miss. diff --git a/docs/adr/ADR-WS-001-platform-as-packages-and-its-boundaries.md b/docs/adr/ADR-WS-001-platform-as-packages-and-its-boundaries.md new file mode 100644 index 00000000..77cf914d --- /dev/null +++ b/docs/adr/ADR-WS-001-platform-as-packages-and-its-boundaries.md @@ -0,0 +1,56 @@ +# ADR-WS-001: The WebSocket platform ships as packages in one leaf, with machine-checked boundaries + +- Status: accepted +- Date: 2026-08-25 +- Scope: `:adapter:inbound:websocket` + +## Context + +The realtime connection platform design models itself as eighteen Gradle modules under +`modules/websocket`, each with a declared purity grade and a declared set of allowed dependencies. +This repository's `src/config/architecture/modules.json` is a fail-closed registry that owns the +leaf list; adding eighteen leaves is a registry change of a size that needs its own decision, and +HARD-STOP #5 forbids doing it implicitly. + +Three earlier platforms in this repository — JPA, GraphQL, and the HTTP platform — met the same +situation and resolved it the same way. + +## Decision + +The eighteen design modules ship as packages inside the single registered leaf. `WebSocketStableModule` +declares each one's package, purity grade and exact allowed edges, and `WebSocketModuleBoundaryTest` +scans the production tree and fails when the declaration and the tree disagree in either direction. + +Three deviations from the design's module map were forced by the check and are recorded in +`docs/websocket/repository-adaptation.md`: `WebSocketSubprotocolName` moved to `core` and the codec +moved to its own FRAMEWORK_BOUND module, both to avoid cycles the design's placement created here; +and the `budget -> core` edge was inverted because `budget` imports nothing from `core`. + +## Consequences + +**The boundary is enforced, not documented.** Six violations were caught during implementation that +a document would not have: two would-be cycles, a duplicate module declaration where two ids claimed +one package, and three undeclared edges. The duplicate is the instructive one — with two ids on one +package, ownership depends on iteration order and one module's rules silently apply to nothing. A +guard against it is now part of the boundary test. + +**The detector had a hole.** Its framework-import list named `com.fasterxml` (Jackson 2) and not +`tools.jackson` (Jackson 3), which is what Spring 7 actually uses — so a CORE module could have +imported a mapper unnoticed. Fixed here and in the HTTP platform, which shared the list. + +**Promotion stays cheap.** Each enum constant is already shaped like a leaf specification, so +splitting one out later is a registry edit rather than an archaeology exercise. + +**The design's own rules were kept where they cost something.** `core` names no framework, so the +same decisions serve both runtimes and are testable without a server; no Java class name reaches the +wire; the payload is an encoded string rather than a map; and handlers are given no way to write, +which is what makes ordering and backpressure guarantees rather than conventions. + +## Alternatives considered + +**Register eighteen leaves.** Faithful to the design and a large change to a fail-closed registry +for a platform that ships as one artifact either way. Rejected as disproportionate; the boundary +test provides the property the modules were for. + +**Ship the modules as packages with no enforcement.** Cheapest, and it makes the boundary a claim. +The six violations found during implementation are the argument against it. diff --git a/docs/adr/ADR-WS-002-resume-and-cluster.md b/docs/adr/ADR-WS-002-resume-and-cluster.md new file mode 100644 index 00000000..54c0f375 --- /dev/null +++ b/docs/adr/ADR-WS-002-resume-and-cluster.md @@ -0,0 +1,60 @@ +# ADR-WS-002: Resume and cluster state are caches, and are treated as caches + +- Status: Accepted +- Date: 2026-08-25 +- Scope: `adapter:inbound:websocket` — `advanced.resume`, `advanced.cluster`, `advanced.presence` + +## Context + +Advanced Tasks 2–8 add three things that all look like state and are not: a resume token that says +where a client got to, a cluster index that says which node holds a session, and a presence summary +derived from that index. + +Each is a statement about the past. The resume token was minted before the disconnect; the index +entry was written by a node that may since have died; presence is a read of the index and inherits +everything wrong with it. The failure this ADR exists to prevent is treating any of them as current +fact, because each reads as one at the call site. + +## Decision + +**Resume is bounded by what the replay store actually holds, not by what the token claims.** +`ResumeCoordinator` consults `ReplayAvailability` before honouring a position. A token that names a +position the store has evicted produces a resynchronise, not a gap-filled stream. The alternative — +trusting the token — silently delivers a stream with a hole in it, which is worse than an explicit +resynchronise because the client believes it is complete. + +**Cluster index entries carry an observation time and are checked against it on every read.** +`ExternalSessionSummary.staleAt` exists so that "the index says edge-2" cannot be used without also +answering "as of when". An entry whose node stopped reporting is not evidence that the node holds +the session. + +**Durable fan-out is deduplicated by stream position, not by message id.** At-least-once is the +contract, so redelivery is normal operation: a redeploy, a slow consumer or a broker rebalance all +produce it. `FanoutDeduplicator` keys on `(stream, position)` and advances a high-water mark under +`compute`, so two consumer threads cannot both deliver the same position. + +**Presence has four states, not two.** `OFFLINE` is a reported fact; `STALE` is the absence of one. +Collapsing them reports every user as disconnected during a Redis partition, when what happened is +that the index went dark and the connections are fine. + +**Nothing security-relevant may depend on presence.** An attacker who can make a node stop reporting +can move the platform's belief about who is present. Presence answers "show a green dot". + +## Consequences + +- A resume that cannot be honoured is visible to the client as a resynchronise. Clients must + implement one; there is no mode in which the platform silently pretends. +- Every read of the cluster index needs a clock. This is deliberate friction. +- `PresenceSummary.classify` refuses an idle window at or past the stale window, because otherwise + `IDLE` is unreachable and the caller believes it has a four-state model when it has three. +- Fan-out envelopes carry a bounded reference and the catalog-encoded document, never a business + object. A rolling deploy has two versions of the code reading the same envelope. + +## Alternatives considered + +- **Trust the resume token.** Rejected: it makes a gap indistinguishable from a complete stream. +- **Deduplicate by message id.** Rejected: a broker that redelivers may re-mint ids, and a producer + that retries certainly does. Position is the property the ordering actually has. +- **A single `online` boolean.** Rejected for the partition case above. +- **Write presence separately from the session index.** Rejected: two sources of truth for "who is + connected" drift, and the drift is invisible — both look plausible and nothing reconciles them. diff --git a/docs/adr/ADR-WS-003-stomp-and-broker-relay.md b/docs/adr/ADR-WS-003-stomp-and-broker-relay.md new file mode 100644 index 00000000..b95a44db --- /dev/null +++ b/docs/adr/ADR-WS-003-stomp-and-broker-relay.md @@ -0,0 +1,74 @@ +# ADR-WS-003: STOMP is an Advanced adapter with a declared destination catalog + +- Status: Accepted +- Date: 2026-08-25 +- Scope: `adapter:inbound:websocket` — `advanced.stomp`, `advanced.stomp.rabbit` + +## Context + +Advanced Tasks 9–13 add STOMP 1.2 alongside the platform's own protocol, plus a RabbitMQ broker +relay and cross-node user destinations. + +This leaf already ships an older STOMP-over-SockJS channel (`stomp`, gated on +`ca-skeleton.websocket.enabled`). Two `@EnableWebSocketMessageBroker` configurations in one context +do not conflict loudly — both contribute a configurer, both call `configureMessageBroker`, and the +broker that results is whichever ran last. Nothing errors and nothing logs. + +STOMP also brings a destination model that is a free string from the client. Without a catalog, the +set of reachable destinations is whatever the broker accepts, which for the simple broker is every +string. + +## Decision + +**The Advanced adapter is its own module (`advanced-stomp`), separate from `advanced`.** It is the +one Advanced capability that cannot be pure — STOMP here *is* the Spring Messaging types — and +folding it into `advanced` would relax that module's purity for every capability in it. + +**The relay is a further module (`advanced-stomp-rabbit`).** The adapter parses a protocol; the +relay opens a TCP connection to somebody else's broker and makes every delivery depend on it. +Different blast radius, different decision, different module. + +**Destinations are declared, per operation.** `StompDestinationCatalog` maps `(operation, +destination)` to a required permission. Undeclared is refused. `SUBSCRIBE` and `SEND` are separate +declarations, because reading a feed and publishing into it are different rights. + +**The authorization decision is a value, not an interceptor method.** `StompAuthorizationPolicy` +returns a `StompAuthorizationDecision`; `StompSecurityInterceptor` only extracts and enforces. A rule +reachable only through a `MessageChannel` gets tested for the cases somebody built a channel for. + +**Only one STOMP runtime may run.** `StompBrokerExclusivity` fails the context when both channels +are enabled, when both brokers are, or when the adapter is enabled with no broker behind it. + +**A `RECEIPT` is never promoted to a commit.** `StompEvidence` has six stages and +`StompAckPolicy.evidenceForReceipt()` is fixed at `PROTOCOL_RECEIPT`. The receipt is written by the +protocol layer, which knows nothing about whether the work succeeded. + +**The simple broker declares what it cannot do.** `SimpleBrokerProfile` cannot be constructed +claiming cluster support or durable acks, and refuses activation outside local/test — in a +multi-node deployment it does not error, it delivers to whichever fraction of users is on the +publishing node. + +**Unresolved user destinations are broadcast once and then dead-lettered.** +`MultiNodeUserDestination` distinguishes a message that arrived *via* the broadcast from one that did +not. Without that, every node rebroadcasts every unresolvable message on receipt. + +## Consequences + +- Enabling Advanced STOMP requires disabling the legacy channel. There is no migration path that + runs both; the exclusivity check makes that explicit at startup rather than at 3am. +- A deployment must write its own catalog. There is deliberately no default: an empty one refuses + every frame and reads as a broken adapter, and a non-empty one publishes destinations nobody chose. +- The relay's cost is one broker connection per authenticated session plus one system connection. + `brokerConnectionsFor` exists so this is computed before the first outage. +- User-destination metrics are tagged with `UserDestinationAction`, never the destination — a user + destination contains a user identifier by construction. + +## Alternatives considered + +- **Extend the existing `stomp` package.** Rejected: it is Stable, and WS-ARCH-6 forbids a Stable + module naming an Advanced one. Making the legacy channel profile-driven would have required that + edge. +- **One `advanced-stomp` module including the relay.** Rejected: the relay is a separate operational + decision and deserves to be refusable on its own. +- **Allow undeclared destinations with a wildcard permission.** Rejected: the wildcard becomes the + default and the catalog becomes documentation. diff --git a/docs/architecture/graphql-api-surface.txt b/docs/architecture/graphql-api-surface.txt index cbf76d5f..efe00c39 100644 --- a/docs/architecture/graphql-api-surface.txt +++ b/docs/architecture/graphql-api-surface.txt @@ -5,7 +5,7 @@ # split into capability artifacts. # Update only after review with: # ./gradlew :adapter:inbound:graphql:updateGraphQlApiSurface -PapproveGraphQlApiSurfaceChange -# types: 398 +# types: 408 dev.caskeleton.adapter.inbound.graphql.HealthGraphqlController dev.caskeleton.adapter.inbound.graphql.advanced.admin.GraphQlAdminPrincipal dev.caskeleton.adapter.inbound.graphql.advanced.admin.GraphQlPersistedOperationAdminAuthorization @@ -100,6 +100,7 @@ dev.caskeleton.adapter.inbound.graphql.advanced.rsocket.GraphQlRSocketAdmission dev.caskeleton.adapter.inbound.graphql.advanced.rsocket.GraphQlRSocketAuthentication dev.caskeleton.adapter.inbound.graphql.advanced.rsocket.GraphQlRSocketCapability dev.caskeleton.adapter.inbound.graphql.advanced.rsocket.GraphQlRSocketErrorMapper +dev.caskeleton.adapter.inbound.graphql.advanced.rsocket.GraphQlRSocketHandlerFactory dev.caskeleton.adapter.inbound.graphql.advanced.rsocket.GraphQlRSocketProperties dev.caskeleton.adapter.inbound.graphql.advanced.rsocket.GraphQlRSocketRoutePolicy dev.caskeleton.adapter.inbound.graphql.advanced.rsocket.GraphQlRSocketRouteRejectedException @@ -110,8 +111,16 @@ dev.caskeleton.adapter.inbound.graphql.advanced.security.GraphQlWebSocketCloseRe dev.caskeleton.adapter.inbound.graphql.advanced.security.GraphQlWebSocketCredentialExpiry dev.caskeleton.adapter.inbound.graphql.advanced.security.GraphQlWebSocketPrincipal dev.caskeleton.adapter.inbound.graphql.advanced.security.GraphQlWebSocketRevocationSignal +dev.caskeleton.adapter.inbound.graphql.advanced.springdata.GraphQlRepositoryAllowlist +dev.caskeleton.adapter.inbound.graphql.advanced.springdata.GraphQlRepositoryArgumentPolicy +dev.caskeleton.adapter.inbound.graphql.advanced.springdata.GraphQlRepositoryExposure +dev.caskeleton.adapter.inbound.graphql.advanced.springdata.GraphQlRepositoryExposureRejectedException +dev.caskeleton.adapter.inbound.graphql.advanced.springdata.GraphQlRepositoryExposureValidator +dev.caskeleton.adapter.inbound.graphql.advanced.springdata.GraphQlRepositoryPaginationPolicy +dev.caskeleton.adapter.inbound.graphql.advanced.springdata.GraphQlRepositoryProjectionPolicy dev.caskeleton.adapter.inbound.graphql.advanced.sse.GraphQlSseAdmission dev.caskeleton.adapter.inbound.graphql.advanced.sse.GraphQlSseConnectionPolicy +dev.caskeleton.adapter.inbound.graphql.advanced.sse.GraphQlSseHandlerFactory dev.caskeleton.adapter.inbound.graphql.advanced.sse.GraphQlSseHeartbeat dev.caskeleton.adapter.inbound.graphql.advanced.sse.GraphQlSseProperties dev.caskeleton.adapter.inbound.graphql.advanced.sse.GraphQlSseRejectedException @@ -134,6 +143,7 @@ dev.caskeleton.adapter.inbound.graphql.advanced.subscription.GraphQlSubscription dev.caskeleton.adapter.inbound.graphql.advanced.subscription.GraphQlSubscriptionTermination dev.caskeleton.adapter.inbound.graphql.advanced.websocket.GraphQlWebSocketAdmission dev.caskeleton.adapter.inbound.graphql.advanced.websocket.GraphQlWebSocketConnectionId +dev.caskeleton.adapter.inbound.graphql.advanced.websocket.GraphQlWebSocketHandlerFactory dev.caskeleton.adapter.inbound.graphql.advanced.websocket.GraphQlWebSocketLifecycle dev.caskeleton.adapter.inbound.graphql.advanced.websocket.GraphQlWebSocketProperties dev.caskeleton.adapter.inbound.graphql.advanced.websocket.GraphQlWebSocketProtocol diff --git a/docs/architecture/jpa-api-surface.txt b/docs/architecture/jpa-api-surface.txt index 7fdf56a2..17a6f096 100644 --- a/docs/architecture/jpa-api-surface.txt +++ b/docs/architecture/jpa-api-surface.txt @@ -5,7 +5,7 @@ # root yet. # Update only after review with: # ./gradlew :adapter:outbound:persistence-jpa:updateJpaApiSurface -PapproveJpaApiSurfaceChange -# types: 332 +# types: 338 dev.caskeleton.adapter.outbound.persistence.api.PersistenceOperationName dev.caskeleton.adapter.outbound.persistence.api.capability.CapabilitySupport dev.caskeleton.adapter.outbound.persistence.api.capability.JpaCapability @@ -165,6 +165,9 @@ dev.caskeleton.adapter.outbound.persistence.idempotency.IdempotencyResponseObjec dev.caskeleton.adapter.outbound.persistence.idempotency.IdempotencyStoreAdapter dev.caskeleton.adapter.outbound.persistence.idempotency.entity.IdempotencyRecordEntity dev.caskeleton.adapter.outbound.persistence.idempotency.mapper.IdempotencyRecordEntityMapper +dev.caskeleton.adapter.outbound.persistence.liveevent.JpaLiveEventReplayAdapter +dev.caskeleton.adapter.outbound.persistence.liveevent.LiveEventJpaRepository +dev.caskeleton.adapter.outbound.persistence.liveevent.entity.LiveEventEntity dev.caskeleton.adapter.outbound.persistence.lock.DistributedLockPersistenceConfig dev.caskeleton.adapter.outbound.persistence.lock.LockRegistryDistributedLockAdapter dev.caskeleton.adapter.outbound.persistence.lock.LockSettings @@ -235,6 +238,9 @@ dev.caskeleton.adapter.outbound.persistence.observation.JpaTransactionObservatio dev.caskeleton.adapter.outbound.persistence.observation.LowCardinality dev.caskeleton.adapter.outbound.persistence.observation.MicrometerQueryObservation dev.caskeleton.adapter.outbound.persistence.observation.SqlDiagnosticRedactor +dev.caskeleton.adapter.outbound.persistence.operation.DurableOperationJpaRepository +dev.caskeleton.adapter.outbound.persistence.operation.DurableOperationStoreAdapter +dev.caskeleton.adapter.outbound.persistence.operation.entity.DurableOperationEntity dev.caskeleton.adapter.outbound.persistence.outbox.OutboxClaimRepository dev.caskeleton.adapter.outbound.persistence.outbox.OutboxEventJpaRepository dev.caskeleton.adapter.outbound.persistence.outbox.OutboxReaper diff --git a/docs/graphql-superpowers-package/MANIFEST.sha256 b/docs/graphql-superpowers-package/MANIFEST.sha256 new file mode 100644 index 00000000..f6000c29 --- /dev/null +++ b/docs/graphql-superpowers-package/MANIFEST.sha256 @@ -0,0 +1,6 @@ +44ba9931722364a53fcb3b5f31a1d539eabcaf42db775f5a33fb558f558c7504 README.md +d064f0ac6c3be0e5c76ef22454db2a97e1d78ed287bd22f4c125f19aba3ad8e3 VALIDATION.md +1ef15812f33dc998a6332b87523ed5942ba46d79d984a0ca776b05bb9247a06a docs/superpowers/plans/2026-08-12-graphql-advanced-capabilities-expansion-plan.md +5ae70b53e22cdb852b2bb0df171dec868bfe99b15bb8e71fb2b0b3431cd7e2cd docs/superpowers/plans/2026-08-12-graphql-api-execution-platform-implementation-plan.md +8d0203203f6bfe4b2e18625eff23bb308ba6454703a4ca4cd3236dab31ecafc3 docs/superpowers/specs/2026-08-12-graphql-api-execution-platform-design.md +8048fe6a536de67d2cf5b0df05d35128f2c68ba8f0dd615831b40430fc76277b validate_graphql_docs.py diff --git a/docs/graphql-superpowers-package/README.md b/docs/graphql-superpowers-package/README.md new file mode 100644 index 00000000..4eff19b3 --- /dev/null +++ b/docs/graphql-superpowers-package/README.md @@ -0,0 +1,43 @@ +# GraphQL Superpowers 설계 패키지 + +이 패키지는 `GraphQL API 실행 플랫폼 심층 리서치`를 구현 기준선으로 변환한 설계서와 실행 계획서다. + +## 문서 + +- `docs/superpowers/specs/2026-08-12-graphql-api-execution-platform-design.md` + - Stable·Advanced 전체 아키텍처, 공개 계약, 경계, 실패 의미론, 테스트와 지원 등급 + - 입력 심층 리서치 원문을 추적 부록으로 포함 +- `docs/superpowers/plans/2026-08-12-graphql-api-execution-platform-implementation-plan.md` + - Stable 구현 Task 1–48 +- `docs/superpowers/plans/2026-08-12-graphql-advanced-capabilities-expansion-plan.md` + - Stable Release Gate 이후 실행하는 Advanced·Experimental Task 1–19 +- `VALIDATION.md` + - 정적 검증 결과와 검증 범위 +- `validate_graphql_docs.py` + - 패키지 내부 문서 재검증 스크립트 +- `MANIFEST.sha256` + - 패키지 파일 무결성 목록 + +## 구현 순서 + +```text +Stable Task 1–48 +→ Stable Release Gate +→ Advanced Task 1–19 +→ Capability별 Promotion Gate +``` + +## 명시적 전제 + +```text +Java 21 +Gradle Kotlin DSL +Spring Boot 4.1 BOM +Spring for GraphQL 2.0 +Boot-managed GraphQL Java v25 계열 +Stable module root: modules/graphql +Advanced module root: modules/graphql-advanced +Root package: io.backend.skeleton.graphql +``` + +실제 저장소에 적용할 때 기존 package·version catalog·module naming에 맞춰 경로만 조정하고, 문서의 공개 계약·불변 조건·테스트 의미는 유지한다. diff --git a/docs/graphql-superpowers-package/VALIDATION.md b/docs/graphql-superpowers-package/VALIDATION.md new file mode 100644 index 00000000..725a4b2a --- /dev/null +++ b/docs/graphql-superpowers-package/VALIDATION.md @@ -0,0 +1,98 @@ +# GraphQL Superpowers 문서 정적 검증 결과 + +- **검증 시각 기준:** 2026-08-12 +- **검증 대상:** 설계서 1개, Stable 구현 계획서 1개, Advanced·Experimental 확장 계획서 1개 +- **검증 명령:** `python3 validate_graphql_docs.py` +- **결과:** **PASS** +- **실행 검사:** 1,475 +- **통과:** 1,475 +- **실패:** 0 + +## 문서 규모 + +| 문서 | 행 수 | 크기 | +|---|---:|---:| +| GraphQL API 실행 플랫폼 설계서 | 2,553 | 93,359 bytes | +| Stable 구현 계획서 | 4,560 | 209,041 bytes | +| Advanced 확장 계획서 | 1,976 | 105,717 bytes | + +## 계획 구조 + +| 항목 | Stable | Advanced | +|---|---:|---:| +| Task 수 | 48 | 19 | +| Create 경로 수 | 227 | 113 | +| Task 번호 연속성 | PASS | PASS | +| 모든 Task의 `Files`·`Interfaces` | PASS | PASS | +| 모든 Task의 Implementation Requirements | PASS | PASS | +| 모든 Task의 Step 1–5 | PASS | PASS | +| 실패·통과 예상 결과 | PASS | PASS | +| Task별 Git commit 명령 | PASS | PASS | +| Create 경로 중복 | 없음 | 없음 | +| Stable·Advanced 경로 충돌 | 없음 | 없음 | + +## 핵심 계약 검증 + +```text +SDL-first external contract +Single Executable Schema Stable default +HTTP POST Stable profile +application/graphql-response+json preferred +Validation 이후 Field Error·Partial Data는 HTTP 200 +Draft 294는 Stable에서 제외 +JPA Entity·MongoDB Document 직접 노출 금지 +GraphQL Multipart Upload 미지원·Fileserver 사용 +request-wide database transaction 금지 +DataLoader request scope +Finite Fetch Profile +HMAC-signed cursor +Mutation idempotency·expected version 분리 +Parser·shape·complexity·runtime response budget +Actor·Field·Object·Tenant authorization +Low-cardinality observability +Stable/Advanced dependency isolation +Persisted Operation·WebSocket·SSE·Federation 분리 +RSocket·HTTP GET·Incremental Delivery Experimental +``` + +위 계약은 설계서와 계획서의 필수 문자열·모듈 경로·Task별 파일·테스트를 대조해 검증했습니다. + +## 입력 리서치 추적성 + +- 첨부된 `GraphQL API 실행 플랫폼 심층 리서치` 원문 전체가 설계서의 `부록 B`에 포함되어 있습니다. +- 설계 본문은 원문의 용어와 결론을 유지하면서 구현 판단을 Stable·Advanced·Experimental로 고정합니다. +- 설계서와 입력 원문의 exact text 포함 검사를 별도로 통과했습니다. + +## 패키지 검증 항목 + +```text +문서 파일 존재 +Markdown code fence 균형 +Task 1–48 / 1–19 연속성 +Task별 테스트·명령·commit +정확한 Create 경로 +Placeholder 금지 +Stable module에 WebSocket·Federation·Persisted Operation 경로 부재 +Advanced module에 feature flag와 capability 경로 존재 +금지 API pattern 부재 +문서 SHA-256 계산 +``` + +## 검증 범위의 한계 + +현재 PASS는 **문서의 정적 구조, 요구사항 추적성, 내부 계약과 실행 계획의 완결성**을 의미합니다. 실제 Backend Skeleton 저장소가 입력으로 제공되지 않았으므로 다음은 실행하지 않았습니다. + +```text +Gradle configuration·compile +Spring Boot ApplicationContext 기동 +SchemaMappingInspector 실제 결과 +GraphQlTester HTTP·WebFlux contract +JPA·MongoDB statement/query-count integration +query bomb·complexity load test +Virtual Thread·event-loop blocking test +WebSocket·SSE soak test +Federation composition·router integration +actual Git commit +``` + +실제 구현에서는 Stable Task 1–48을 먼저 수행해 Stable Release Gate를 통과한 뒤 Advanced Task 1–19를 시작해야 합니다. diff --git a/docs/graphql-superpowers-package/docs/superpowers/plans/2026-08-12-graphql-advanced-capabilities-expansion-plan.md b/docs/graphql-superpowers-package/docs/superpowers/plans/2026-08-12-graphql-advanced-capabilities-expansion-plan.md new file mode 100644 index 00000000..904319ae --- /dev/null +++ b/docs/graphql-superpowers-package/docs/superpowers/plans/2026-08-12-graphql-advanced-capabilities-expansion-plan.md @@ -0,0 +1,1976 @@ +# GraphQL Advanced Capability Expansion Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Stable GraphQL API 실행 플랫폼 위에 Persisted Operation, WebSocket·SSE Subscription, optional replay, Federation Subgraph, Code Generation과 제한된 Spring Data 호환 기능을 추가하고 RSocket·HTTP GET·Incremental Delivery를 격리된 Experimental capability로 검증한다. + +**Architecture:** Advanced capability는 `modules/graphql-advanced`에만 존재하며 Stable module의 public contract를 소비하되 Stable starter의 transitive dependency가 되지 않는다. 모든 capability는 `backend.graphql.advanced.*` feature flag와 capability별 release evidence를 요구하며, 장기 연결·schema composition·provider-specific behavior가 Stable query/mutation path를 오염시키지 않도록 분리한다. + +**Tech Stack:** Stable GraphQL platform, Java 21, Spring Boot 4.1 BOM, Spring for GraphQL 2.0, Boot-managed GraphQL Java v25, GraphQL Java DataLoader, Spring WebFlux WebSocket, Spring GraphQL SSE/RSocket, federation-jvm, Micrometer, JUnit 5, Reactor Test. + +## Global Constraints + +- 이 계획은 Stable 구현 계획 Task `1–48`과 Stable Release Gate가 통과한 후 시작한다. +- Advanced module root는 `modules/graphql-advanced`이다. +- Root package는 `io.backend.skeleton.graphql.advanced`이다. +- 모든 capability는 `backend.graphql.advanced.*` 아래의 명시적 feature flag를 요구한다. +- Stable starter는 Advanced module에 compile·runtime dependency를 갖지 않는다. +- Persisted Operation은 Parse cache 및 Response cache와 다른 기능이다. +- WebSocket protocol은 `graphql-transport-ws`만 지원하고 `subscriptions-transport-ws`를 신규 지원하지 않는다. +- GraphQL Subscription은 Messaging의 ACK·offset·replay·DLQ를 대체하지 않는다. +- Slow consumer 기본 정책은 무음 event drop이 아니라 종료다. +- SSE는 Distinct Connection subscription transport이며 Query·Mutation response mode가 아니다. +- Replay extension의 durability·offset guarantee는 Messaging platform이 소유한다. +- Single Executable Schema는 계속 Stable 기본값이다. +- Federation은 Subgraph capability만 이 저장소에 구현하고 Router/Supergraph 운영은 별도 프로젝트가 소유한다. +- Client/transport DTO code generation은 허용하지만 Domain Entity·Use Case·Repository generation은 금지한다. +- Spring Data automatic GraphQL repository exposure는 allowlist compatibility module로만 제공한다. +- RSocket, HTTP GET, Incremental Delivery는 승격 ADR 전까지 Experimental이다. +- Advanced capability가 Stable HTTP POST, error, security, cost, DataLoader와 observability guardrail을 우회해서는 안 된다. +- 모든 task는 red-green TDD와 독립 commit으로 끝난다. + +--- + +## Advanced Module Map + +```text +modules/graphql-advanced/ +├── graphql-advanced-bootstrap +├── graphql-persisted-operation +├── graphql-websocket +├── graphql-subscription +├── graphql-sse +├── graphql-dataloader-chaining +├── graphql-federation +├── graphql-codegen +├── graphql-spring-data-compat +├── graphql-rsocket +├── graphql-http-draft +└── graphql-incremental-delivery +``` + +## Capability Classification + +| Capability | Initial grade | Promotion evidence | +|---|---|---| +| Persisted Operation | Advanced Stable | durable registry, block propagation, schema/usage gate | +| WebSocket Subscription | Advanced Stable | auth, backpressure, soak, cancellation, drain | +| SSE Subscription | Advanced | HTTP/2 connection scale, proxy behavior, auth | +| Messaging-backed Replay | Advanced Extension | snapshot/live gap, history loss, authorization | +| Chained DataLoader | Advanced | dispatch/query-count regression | +| Federation Subgraph | Advanced | composition, router integration, failure/latency | +| Client Codegen | Optional Stable Tooling | generated-source compatibility | +| Spring Data Compat | Restricted Advanced | allowlist, projection, pagination/query guard | +| RSocket | Experimental | explicit consumers and transport evidence | +| HTTP GET | Experimental | draft, cache, CSRF and URL disclosure evidence | +| Incremental Delivery | Experimental | engine/transport interoperability and client negotiation | + +## Delivery Phases + +| Phase | Tasks | Result | +|---|---:|---| +| Boundary | 1 | Stable/Advanced dependency and feature flag isolation | +| Persisted Operations | 2–4 | Registry, execution lookup and G4 admin | +| Live Transport | 5–10 | WebSocket, auth, backpressure, ordering, SSE, replay | +| Data·Schema Extensions | 11–15 | Chained loader, Federation, Codegen, Spring Data compat | +| Experimental Transport | 16–18 | RSocket, HTTP GET, Incremental Delivery | +| Promotion | 19 | Capability-specific release and promotion gate | + +--- + +### Task 1: Advanced Module Boundary와 Feature Flag + +**Files:** +- Create: `modules/graphql-advanced/graphql-advanced-bootstrap/src/main/java/io/backend/skeleton/graphql/advanced/bootstrap/GraphQlAdvancedCapability.java` +- Create: `modules/graphql-advanced/graphql-advanced-bootstrap/src/main/java/io/backend/skeleton/graphql/advanced/bootstrap/GraphQlAdvancedFeatureFlags.java` +- Create: `modules/graphql-advanced/graphql-advanced-bootstrap/src/main/java/io/backend/skeleton/graphql/advanced/bootstrap/GraphQlAdvancedModuleGuard.java` +- Create: `modules/graphql-advanced/graphql-advanced-bootstrap/src/main/java/io/backend/skeleton/graphql/advanced/bootstrap/GraphQlAdvancedDependencyRules.java` +- Test: `modules/graphql-advanced/graphql-advanced-bootstrap/src/test/java/io/backend/skeleton/graphql/advanced/bootstrap/GraphQlAdvancedModuleGuardTest.java` + +**Interfaces:** +- Consumes: Stable GraphQL API 실행 플랫폼의 public contract와 Spring Boot environment. +- Produces: Advanced·Experimental 모듈이 Stable starter에 자동 유입되지 않도록 하는 dependency·feature flag 경계. + +**Implementation requirements:** +- 모든 Advanced capability는 `backend.graphql.advanced.*` 아래의 명시적 flag를 요구한다. +- Stable starter는 advanced module에 compile/runtime dependency를 갖지 않는다. +- Advanced module은 Stable public types를 소비할 수 있지만 Stable module을 수정하지 않는다. +- Experimental capability는 production에서 별도 승인 profile 없이는 시작되지 않는다. +- Capability 상태는 `ADVANCED_STABLE`, `EXPERIMENTAL`, `DISABLED`로 구분한다. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlAdvancedModuleGuardTest { + @org.junit.jupiter.api.Test + void disabledCapabilityCannotStart() { + var flags = GraphQlAdvancedFeatureFlags.disabled(); + var guard = new GraphQlAdvancedModuleGuard(flags); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> guard.requireEnabled( + GraphQlAdvancedCapability.PERSISTED_OPERATION)) + .isInstanceOf( + GraphQlAdvancedCapabilityDisabledException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql-advanced:graphql-advanced-bootstrap:test --tests 'io.backend.skeleton.graphql.advanced.bootstrap.GraphQlAdvancedModuleGuardTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public enum GraphQlAdvancedCapability { + PERSISTED_OPERATION, + WEBSOCKET_SUBSCRIPTION, + SSE_SUBSCRIPTION, + FEDERATION_SUBGRAPH, + CODE_GENERATION, + SPRING_DATA_COMPAT, + RSOCKET, + HTTP_GET, + INCREMENTAL_DELIVERY +} + +public record GraphQlAdvancedFeatureFlags( + java.util.Set enabled) { + + public static GraphQlAdvancedFeatureFlags disabled() { + return new GraphQlAdvancedFeatureFlags( + java.util.Set.of()); + } + + public boolean isEnabled( + GraphQlAdvancedCapability capability) { + return enabled.contains(capability); + } +} + +public final class GraphQlAdvancedModuleGuard { + private final GraphQlAdvancedFeatureFlags flags; + + public GraphQlAdvancedModuleGuard( + GraphQlAdvancedFeatureFlags flags) { + this.flags = flags; + } + + public void requireEnabled( + GraphQlAdvancedCapability capability) { + if (!flags.isEnabled(capability)) { + throw new GraphQlAdvancedCapabilityDisabledException( + capability.name()); + } + } +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql-advanced:graphql-advanced-bootstrap:test --tests 'io.backend.skeleton.graphql.advanced.bootstrap.GraphQlAdvancedModuleGuardTest' +./gradlew graphqlAdvancedTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql-advanced/graphql-advanced-bootstrap/src/main/java/io/backend/skeleton/graphql/advanced/bootstrap/GraphQlAdvancedCapability.java' 'modules/graphql-advanced/graphql-advanced-bootstrap/src/main/java/io/backend/skeleton/graphql/advanced/bootstrap/GraphQlAdvancedFeatureFlags.java' 'modules/graphql-advanced/graphql-advanced-bootstrap/src/main/java/io/backend/skeleton/graphql/advanced/bootstrap/GraphQlAdvancedModuleGuard.java' 'modules/graphql-advanced/graphql-advanced-bootstrap/src/main/java/io/backend/skeleton/graphql/advanced/bootstrap/GraphQlAdvancedDependencyRules.java' 'modules/graphql-advanced/graphql-advanced-bootstrap/src/test/java/io/backend/skeleton/graphql/advanced/bootstrap/GraphQlAdvancedModuleGuardTest.java' +git commit -m "build: isolate graphql advanced modules" +``` + +### Task 2: Persisted Operation Model과 Registry + +**Files:** +- Create: `modules/graphql-advanced/graphql-persisted-operation/src/main/java/io/backend/skeleton/graphql/advanced/persisted/GraphQlPersistedOperationId.java` +- Create: `modules/graphql-advanced/graphql-persisted-operation/src/main/java/io/backend/skeleton/graphql/advanced/persisted/GraphQlPersistedOperation.java` +- Create: `modules/graphql-advanced/graphql-persisted-operation/src/main/java/io/backend/skeleton/graphql/advanced/persisted/GraphQlPersistedOperationStatus.java` +- Create: `modules/graphql-advanced/graphql-persisted-operation/src/main/java/io/backend/skeleton/graphql/advanced/persisted/GraphQlPersistedOperationRegistry.java` +- Create: `modules/graphql-advanced/graphql-persisted-operation/src/main/java/io/backend/skeleton/graphql/advanced/persisted/InMemoryGraphQlPersistedOperationRegistry.java` +- Create: `modules/graphql-advanced/graphql-persisted-operation/src/main/java/io/backend/skeleton/graphql/advanced/persisted/GraphQlPersistedOperationConflictException.java` +- Test: `modules/graphql-advanced/graphql-persisted-operation/src/test/java/io/backend/skeleton/graphql/advanced/persisted/GraphQlPersistedOperationRegistryTest.java` + +**Interfaces:** +- Consumes: Stable schema contract hash, operation name, canonical document hash와 client profile. +- Produces: 승인된 operation document의 versioned registry와 ACTIVE·DEPRECATED·BLOCKED lifecycle. + +**Implementation requirements:** +- Registry record에 operation ID, operation name, SHA-256 document hash, canonical document, schema hash, allowed client profiles, maximum complexity와 variable bytes를 보존한다. +- 같은 operation ID로 다른 document를 등록하면 conflict다. +- BLOCKED operation은 cache에 남아 있어도 실행되지 않는다. +- Raw variables와 credential을 registry에 저장하지 않는다. +- Persistent implementation SPI를 제공하되 Stable DB 선택을 강제하지 않는다. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlPersistedOperationRegistryTest { + @org.junit.jupiter.api.Test + void sameIdWithDifferentDocumentIsConflict() { + var registry = + new InMemoryGraphQlPersistedOperationRegistry(); + registry.register(GraphQlPersistedOperation.active( + "get-order-v1", "GetOrder", "sha256:a", + "query GetOrder { order { id } }", "schema-a")); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> registry.register( + GraphQlPersistedOperation.active( + "get-order-v1", "GetOrder", "sha256:b", + "query GetOrder { order { status } }", + "schema-a"))) + .isInstanceOf( + GraphQlPersistedOperationConflictException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql-advanced:graphql-persisted-operation:test --tests 'io.backend.skeleton.graphql.advanced.persisted.GraphQlPersistedOperationRegistryTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public enum GraphQlPersistedOperationStatus { + ACTIVE, + DEPRECATED, + BLOCKED +} + +public record GraphQlPersistedOperation( + GraphQlPersistedOperationId id, + String operationName, + String documentHash, + String canonicalDocument, + String schemaContractHash, + java.util.Set allowedClientProfiles, + long maximumComplexity, + int maximumVariablesBytes, + GraphQlPersistedOperationStatus status) { + + public static GraphQlPersistedOperation active( + String id, + String operationName, + String documentHash, + String canonicalDocument, + String schemaHash) { + return new GraphQlPersistedOperation( + new GraphQlPersistedOperationId(id), + operationName, documentHash, canonicalDocument, + schemaHash, java.util.Set.of("FIRST_PARTY"), + 10_000, 65_536, + GraphQlPersistedOperationStatus.ACTIVE); + } +} + +public interface GraphQlPersistedOperationRegistry { + void register(GraphQlPersistedOperation operation); + java.util.Optional find( + GraphQlPersistedOperationId id); +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql-advanced:graphql-persisted-operation:test --tests 'io.backend.skeleton.graphql.advanced.persisted.GraphQlPersistedOperationRegistryTest' +./gradlew graphqlAdvancedTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql-advanced/graphql-persisted-operation/src/main/java/io/backend/skeleton/graphql/advanced/persisted/GraphQlPersistedOperationId.java' 'modules/graphql-advanced/graphql-persisted-operation/src/main/java/io/backend/skeleton/graphql/advanced/persisted/GraphQlPersistedOperation.java' 'modules/graphql-advanced/graphql-persisted-operation/src/main/java/io/backend/skeleton/graphql/advanced/persisted/GraphQlPersistedOperationStatus.java' 'modules/graphql-advanced/graphql-persisted-operation/src/main/java/io/backend/skeleton/graphql/advanced/persisted/GraphQlPersistedOperationRegistry.java' 'modules/graphql-advanced/graphql-persisted-operation/src/main/java/io/backend/skeleton/graphql/advanced/persisted/InMemoryGraphQlPersistedOperationRegistry.java' 'modules/graphql-advanced/graphql-persisted-operation/src/main/java/io/backend/skeleton/graphql/advanced/persisted/GraphQlPersistedOperationConflictException.java' 'modules/graphql-advanced/graphql-persisted-operation/src/test/java/io/backend/skeleton/graphql/advanced/persisted/GraphQlPersistedOperationRegistryTest.java' +git commit -m "feat: add graphql persisted operation registry" +``` + +### Task 3: Persisted Operation Lookup과 Execution Interceptor + +**Files:** +- Create: `modules/graphql-advanced/graphql-persisted-operation/src/main/java/io/backend/skeleton/graphql/advanced/persisted/GraphQlPersistedOperationRequest.java` +- Create: `modules/graphql-advanced/graphql-persisted-operation/src/main/java/io/backend/skeleton/graphql/advanced/persisted/GraphQlPersistedOperationLookup.java` +- Create: `modules/graphql-advanced/graphql-persisted-operation/src/main/java/io/backend/skeleton/graphql/advanced/persisted/GraphQlPersistedOperationInterceptor.java` +- Create: `modules/graphql-advanced/graphql-persisted-operation/src/main/java/io/backend/skeleton/graphql/advanced/persisted/GraphQlPersistedOperationPolicy.java` +- Create: `modules/graphql-advanced/graphql-persisted-operation/src/main/java/io/backend/skeleton/graphql/advanced/persisted/GraphQlPersistedOperationRejectedException.java` +- Create: `modules/graphql-advanced/graphql-persisted-operation/src/main/java/io/backend/skeleton/graphql/advanced/persisted/GraphQlPersistedPreparsedBridge.java` +- Test: `modules/graphql-advanced/graphql-persisted-operation/src/test/java/io/backend/skeleton/graphql/advanced/persisted/GraphQlPersistedOperationInterceptorTest.java` + +**Interfaces:** +- Consumes: Persisted registry, Stable request context, client policy, schema hash와 preparsed cache. +- Produces: Operation ID로 canonical document를 복원하고 client·schema·variables·complexity를 재검증하는 interceptor. + +**Implementation requirements:** +- Client가 operation ID와 임의 query를 동시에 보내면 hash 일치 여부를 검증하거나 profile에 따라 거부한다. +- Registry의 allowed client profile과 현재 client가 일치해야 한다. +- Schema contract hash가 현재 schema와 맞지 않으면 실행하지 않는다. +- Persisted record의 maximum complexity·variables limit와 현재 client policy 중 더 엄격한 값을 사용한다. +- Lookup 성공이 authorization 성공을 의미하지 않는다. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlPersistedOperationInterceptorTest { + @org.junit.jupiter.api.Test + void blockedOperationIsRejectedBeforeExecution() { + var operation = GraphQlPersistedOperation.active( + "dangerous-v1", "Dangerous", "sha256:a", + "query Dangerous { expensive }", "schema-a"); + operation = new GraphQlPersistedOperation( + operation.id(), operation.operationName(), + operation.documentHash(), operation.canonicalDocument(), + operation.schemaContractHash(), + operation.allowedClientProfiles(), + operation.maximumComplexity(), + operation.maximumVariablesBytes(), + GraphQlPersistedOperationStatus.BLOCKED); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> GraphQlPersistedOperationPolicy.requireActive( + operation)) + .isInstanceOf( + GraphQlPersistedOperationRejectedException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql-advanced:graphql-persisted-operation:test --tests 'io.backend.skeleton.graphql.advanced.persisted.GraphQlPersistedOperationInterceptorTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public final class GraphQlPersistedOperationPolicy { + public static GraphQlPersistedOperation requireActive( + GraphQlPersistedOperation operation) { + if (operation.status() + != GraphQlPersistedOperationStatus.ACTIVE) { + throw new GraphQlPersistedOperationRejectedException( + "persisted operation is not active"); + } + return operation; + } + + private GraphQlPersistedOperationPolicy() {} +} + +public record GraphQlPersistedOperationRequest( + GraphQlPersistedOperationId operationId, + String suppliedDocumentHash, + int variablesBytes) { +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql-advanced:graphql-persisted-operation:test --tests 'io.backend.skeleton.graphql.advanced.persisted.GraphQlPersistedOperationInterceptorTest' +./gradlew graphqlAdvancedTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql-advanced/graphql-persisted-operation/src/main/java/io/backend/skeleton/graphql/advanced/persisted/GraphQlPersistedOperationRequest.java' 'modules/graphql-advanced/graphql-persisted-operation/src/main/java/io/backend/skeleton/graphql/advanced/persisted/GraphQlPersistedOperationLookup.java' 'modules/graphql-advanced/graphql-persisted-operation/src/main/java/io/backend/skeleton/graphql/advanced/persisted/GraphQlPersistedOperationInterceptor.java' 'modules/graphql-advanced/graphql-persisted-operation/src/main/java/io/backend/skeleton/graphql/advanced/persisted/GraphQlPersistedOperationPolicy.java' 'modules/graphql-advanced/graphql-persisted-operation/src/main/java/io/backend/skeleton/graphql/advanced/persisted/GraphQlPersistedOperationRejectedException.java' 'modules/graphql-advanced/graphql-persisted-operation/src/main/java/io/backend/skeleton/graphql/advanced/persisted/GraphQlPersistedPreparsedBridge.java' 'modules/graphql-advanced/graphql-persisted-operation/src/test/java/io/backend/skeleton/graphql/advanced/persisted/GraphQlPersistedOperationInterceptorTest.java' +git commit -m "feat: execute registered graphql operations" +``` + +### Task 4: G4 Persisted Operation Admin·Usage·Block Plane + +**Files:** +- Create: `modules/graphql-advanced/graphql-persisted-operation/src/main/java/io/backend/skeleton/graphql/advanced/admin/GraphQlPersistedOperationAdminService.java` +- Create: `modules/graphql-advanced/graphql-persisted-operation/src/main/java/io/backend/skeleton/graphql/advanced/admin/GraphQlPersistedOperationUsage.java` +- Create: `modules/graphql-advanced/graphql-persisted-operation/src/main/java/io/backend/skeleton/graphql/advanced/admin/GraphQlPersistedOperationAudit.java` +- Create: `modules/graphql-advanced/graphql-persisted-operation/src/main/java/io/backend/skeleton/graphql/advanced/admin/GraphQlPersistedOperationBlockCommand.java` +- Create: `modules/graphql-advanced/graphql-persisted-operation/src/main/java/io/backend/skeleton/graphql/advanced/admin/GraphQlPersistedOperationRemovalGate.java` +- Create: `modules/graphql-advanced/graphql-persisted-operation/src/main/java/io/backend/skeleton/graphql/advanced/admin/GraphQlPersistedOperationAdminAuthorization.java` +- Test: `modules/graphql-advanced/graphql-persisted-operation/src/test/java/io/backend/skeleton/graphql/advanced/admin/GraphQlPersistedOperationRemovalGateTest.java` + +**Interfaces:** +- Consumes: Persisted registry, schema usage observation, admin actor·audit port. +- Produces: Operation 등록·deprecate·incident block·remove를 감사 가능한 G4 관리 작업으로 제공. + +**Implementation requirements:** +- 일반 GraphQL resolver나 application credential은 admin service에 접근하지 않는다. +- Block는 즉시 실행 경로에 반영되며 cache를 우회하지 않는다. +- Removal은 최근 usage, schema compatibility와 owner 승인 evidence를 요구한다. +- 모든 변경에 operator, reason, before/after, timestamp와 trace ID를 감사한다. +- Raw variables와 credential은 audit에 포함하지 않는다. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlPersistedOperationRemovalGateTest { + @org.junit.jupiter.api.Test + void recentUsageBlocksRemoval() { + var gate = new GraphQlPersistedOperationRemovalGate( + java.time.Duration.ofDays(30)); + var usage = new GraphQlPersistedOperationUsage( + java.time.Instant.now().minus( + java.time.Duration.ofDays(1)), 12); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> gate.verify(usage, java.time.Instant.now())) + .isInstanceOf( + GraphQlPersistedOperationRemovalRejectedException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql-advanced:graphql-persisted-operation:test --tests 'io.backend.skeleton.graphql.advanced.admin.GraphQlPersistedOperationRemovalGateTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GraphQlPersistedOperationUsage( + java.time.Instant lastUsedAt, + long executions) { +} + +public final class GraphQlPersistedOperationRemovalGate { + private final java.time.Duration quietPeriod; + + public GraphQlPersistedOperationRemovalGate( + java.time.Duration quietPeriod) { + this.quietPeriod = quietPeriod; + } + + public void verify( + GraphQlPersistedOperationUsage usage, + java.time.Instant now) { + if (usage.executions() > 0 + && usage.lastUsedAt().plus(quietPeriod) + .isAfter(now)) { + throw new + GraphQlPersistedOperationRemovalRejectedException( + "persisted operation used within quiet period"); + } + } +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql-advanced:graphql-persisted-operation:test --tests 'io.backend.skeleton.graphql.advanced.admin.GraphQlPersistedOperationRemovalGateTest' +./gradlew graphqlAdvancedTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql-advanced/graphql-persisted-operation/src/main/java/io/backend/skeleton/graphql/advanced/admin/GraphQlPersistedOperationAdminService.java' 'modules/graphql-advanced/graphql-persisted-operation/src/main/java/io/backend/skeleton/graphql/advanced/admin/GraphQlPersistedOperationUsage.java' 'modules/graphql-advanced/graphql-persisted-operation/src/main/java/io/backend/skeleton/graphql/advanced/admin/GraphQlPersistedOperationAudit.java' 'modules/graphql-advanced/graphql-persisted-operation/src/main/java/io/backend/skeleton/graphql/advanced/admin/GraphQlPersistedOperationBlockCommand.java' 'modules/graphql-advanced/graphql-persisted-operation/src/main/java/io/backend/skeleton/graphql/advanced/admin/GraphQlPersistedOperationRemovalGate.java' 'modules/graphql-advanced/graphql-persisted-operation/src/main/java/io/backend/skeleton/graphql/advanced/admin/GraphQlPersistedOperationAdminAuthorization.java' 'modules/graphql-advanced/graphql-persisted-operation/src/test/java/io/backend/skeleton/graphql/advanced/admin/GraphQlPersistedOperationRemovalGateTest.java' +git commit -m "feat: add graphql operation admin plane" +``` + +### Task 5: `graphql-transport-ws` WebSocket Protocol Adapter + +**Files:** +- Create: `modules/graphql-advanced/graphql-websocket/src/main/java/io/backend/skeleton/graphql/advanced/websocket/GraphQlWebSocketProtocol.java` +- Create: `modules/graphql-advanced/graphql-websocket/src/main/java/io/backend/skeleton/graphql/advanced/websocket/GraphQlWebSocketProperties.java` +- Create: `modules/graphql-advanced/graphql-websocket/src/main/java/io/backend/skeleton/graphql/advanced/websocket/GraphQlWebSocketHandlerFactory.java` +- Create: `modules/graphql-advanced/graphql-websocket/src/main/java/io/backend/skeleton/graphql/advanced/websocket/GraphQlWebSocketLifecycle.java` +- Create: `modules/graphql-advanced/graphql-websocket/src/main/java/io/backend/skeleton/graphql/advanced/websocket/GraphQlWebSocketConnectionId.java` +- Create: `modules/graphql-advanced/graphql-websocket/src/main/java/io/backend/skeleton/graphql/advanced/websocket/GraphQlWebSocketProtocolError.java` +- Test: `modules/graphql-advanced/graphql-websocket/src/test/java/io/backend/skeleton/graphql/advanced/websocket/GraphQlWebSocketProtocolTest.java` + +**Interfaces:** +- Consumes: Spring GraphQL WebSocket handler, Stable execution service와 advanced feature guard. +- Produces: `graphql-transport-ws` connection_init·subscribe·complete lifecycle와 bounded connection policy. + +**Implementation requirements:** +- 과거 `subscriptions-transport-ws`를 지원 protocol로 광고하지 않는다. +- connection init timeout, idle timeout, maximum connection age와 maximum subscriptions per connection을 설정한다. +- Protocol error는 해당 operation 또는 connection scope에 맞게 종료한다. +- 서버 shutdown에서 신규 subscribe를 거부하고 기존 stream을 drain한다. +- Connection ID와 operation ID를 metric label로 사용하지 않는다. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlWebSocketProtocolTest { + @org.junit.jupiter.api.Test + void connectionInitAfterDeadlineIsRejected() { + var lifecycle = new GraphQlWebSocketLifecycle( + java.time.Duration.ofSeconds(5), + java.time.Instant.parse("2026-08-12T00:00:00Z")); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> lifecycle.onConnectionInit( + java.time.Instant.parse( + "2026-08-12T00:00:06Z"))) + .isInstanceOf(GraphQlWebSocketProtocolError.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql-advanced:graphql-websocket:test --tests 'io.backend.skeleton.graphql.advanced.websocket.GraphQlWebSocketProtocolTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public enum GraphQlWebSocketProtocol { + GRAPHQL_TRANSPORT_WS("graphql-transport-ws"); + + private final String subProtocol; + + GraphQlWebSocketProtocol(String subProtocol) { + this.subProtocol = subProtocol; + } + + public String subProtocol() { + return subProtocol; + } +} + +public final class GraphQlWebSocketLifecycle { + private final java.time.Duration initTimeout; + private final java.time.Instant connectedAt; + + public GraphQlWebSocketLifecycle( + java.time.Duration initTimeout, + java.time.Instant connectedAt) { + this.initTimeout = initTimeout; + this.connectedAt = connectedAt; + } + + public void onConnectionInit(java.time.Instant now) { + if (now.isAfter(connectedAt.plus(initTimeout))) { + throw new GraphQlWebSocketProtocolError( + "connection_init timeout"); + } + } +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql-advanced:graphql-websocket:test --tests 'io.backend.skeleton.graphql.advanced.websocket.GraphQlWebSocketProtocolTest' +./gradlew graphqlAdvancedTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql-advanced/graphql-websocket/src/main/java/io/backend/skeleton/graphql/advanced/websocket/GraphQlWebSocketProtocol.java' 'modules/graphql-advanced/graphql-websocket/src/main/java/io/backend/skeleton/graphql/advanced/websocket/GraphQlWebSocketProperties.java' 'modules/graphql-advanced/graphql-websocket/src/main/java/io/backend/skeleton/graphql/advanced/websocket/GraphQlWebSocketHandlerFactory.java' 'modules/graphql-advanced/graphql-websocket/src/main/java/io/backend/skeleton/graphql/advanced/websocket/GraphQlWebSocketLifecycle.java' 'modules/graphql-advanced/graphql-websocket/src/main/java/io/backend/skeleton/graphql/advanced/websocket/GraphQlWebSocketConnectionId.java' 'modules/graphql-advanced/graphql-websocket/src/main/java/io/backend/skeleton/graphql/advanced/websocket/GraphQlWebSocketProtocolError.java' 'modules/graphql-advanced/graphql-websocket/src/test/java/io/backend/skeleton/graphql/advanced/websocket/GraphQlWebSocketProtocolTest.java' +git commit -m "feat: add graphql websocket protocol" +``` + +### Task 6: WebSocket Authentication Expiry·Revocation Lifecycle + +**Files:** +- Create: `modules/graphql-advanced/graphql-websocket/src/main/java/io/backend/skeleton/graphql/advanced/security/GraphQlWebSocketAuthenticationInterceptor.java` +- Create: `modules/graphql-advanced/graphql-websocket/src/main/java/io/backend/skeleton/graphql/advanced/security/GraphQlWebSocketPrincipal.java` +- Create: `modules/graphql-advanced/graphql-websocket/src/main/java/io/backend/skeleton/graphql/advanced/security/GraphQlWebSocketCredentialExpiry.java` +- Create: `modules/graphql-advanced/graphql-websocket/src/main/java/io/backend/skeleton/graphql/advanced/security/GraphQlWebSocketRevocationSignal.java` +- Create: `modules/graphql-advanced/graphql-websocket/src/main/java/io/backend/skeleton/graphql/advanced/security/GraphQlSubscriptionAuthorizationPolicy.java` +- Create: `modules/graphql-advanced/graphql-websocket/src/main/java/io/backend/skeleton/graphql/advanced/security/GraphQlWebSocketCloseReason.java` +- Test: `modules/graphql-advanced/graphql-websocket/src/test/java/io/backend/skeleton/graphql/advanced/security/GraphQlWebSocketAuthenticationInterceptorTest.java` + +**Interfaces:** +- Consumes: connection_init payload, security adapter, actor·tenant context, credential expiry와 revocation signal. +- Produces: Connection과 subscription에 actor context를 고정하고 expiry·revocation 시 fail-closed 종료하는 lifecycle. + +**Implementation requirements:** +- connection_init payload의 credential을 metric·log에 남기지 않는다. +- 인증된 actor·tenant를 이후 operation context에 전달한다. +- Credential expiry가 오면 connection을 종료하고 silent refresh를 임의 구현하지 않는다. +- Resource 권한이 변할 수 있는 sensitive subscription은 event delivery 전 재검증 profile을 지원한다. +- 한 connection에서 tenant context를 operation마다 바꿀 수 없다. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlWebSocketAuthenticationInterceptorTest { + @org.junit.jupiter.api.Test + void expiredCredentialClosesConnection() { + var expiry = new GraphQlWebSocketCredentialExpiry( + java.time.Instant.parse("2026-08-12T00:00:00Z")); + + org.assertj.core.api.Assertions.assertThat( + expiry.isExpired( + java.time.Instant.parse( + "2026-08-12T00:00:01Z"))) + .isTrue(); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql-advanced:graphql-websocket:test --tests 'io.backend.skeleton.graphql.advanced.security.GraphQlWebSocketAuthenticationInterceptorTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GraphQlWebSocketCredentialExpiry( + java.time.Instant expiresAt) { + + public boolean isExpired(java.time.Instant now) { + return !now.isBefore(expiresAt); + } +} + +public record GraphQlWebSocketPrincipal( + String actorFingerprint, + String tenantFingerprint, + java.time.Instant expiresAt) { +} + +public enum GraphQlWebSocketCloseReason { + AUTHENTICATION_FAILED, + CREDENTIAL_EXPIRED, + AUTHORIZATION_REVOKED, + SERVER_DRAINING +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql-advanced:graphql-websocket:test --tests 'io.backend.skeleton.graphql.advanced.security.GraphQlWebSocketAuthenticationInterceptorTest' +./gradlew graphqlAdvancedTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql-advanced/graphql-websocket/src/main/java/io/backend/skeleton/graphql/advanced/security/GraphQlWebSocketAuthenticationInterceptor.java' 'modules/graphql-advanced/graphql-websocket/src/main/java/io/backend/skeleton/graphql/advanced/security/GraphQlWebSocketPrincipal.java' 'modules/graphql-advanced/graphql-websocket/src/main/java/io/backend/skeleton/graphql/advanced/security/GraphQlWebSocketCredentialExpiry.java' 'modules/graphql-advanced/graphql-websocket/src/main/java/io/backend/skeleton/graphql/advanced/security/GraphQlWebSocketRevocationSignal.java' 'modules/graphql-advanced/graphql-websocket/src/main/java/io/backend/skeleton/graphql/advanced/security/GraphQlSubscriptionAuthorizationPolicy.java' 'modules/graphql-advanced/graphql-websocket/src/main/java/io/backend/skeleton/graphql/advanced/security/GraphQlWebSocketCloseReason.java' 'modules/graphql-advanced/graphql-websocket/src/test/java/io/backend/skeleton/graphql/advanced/security/GraphQlWebSocketAuthenticationInterceptorTest.java' +git commit -m "feat: secure graphql websocket lifecycle" +``` + +### Task 7: Subscription Source SPI와 Bounded Backpressure + +**Files:** +- Create: `modules/graphql-advanced/graphql-subscription/src/main/java/io/backend/skeleton/graphql/advanced/subscription/GraphQlSubscriptionSource.java` +- Create: `modules/graphql-advanced/graphql-subscription/src/main/java/io/backend/skeleton/graphql/advanced/subscription/GraphQlSubscriptionEvent.java` +- Create: `modules/graphql-advanced/graphql-subscription/src/main/java/io/backend/skeleton/graphql/advanced/subscription/GraphQlSubscriptionContext.java` +- Create: `modules/graphql-advanced/graphql-subscription/src/main/java/io/backend/skeleton/graphql/advanced/subscription/GraphQlSubscriptionBufferPolicy.java` +- Create: `modules/graphql-advanced/graphql-subscription/src/main/java/io/backend/skeleton/graphql/advanced/subscription/GraphQlSlowConsumerPolicy.java` +- Create: `modules/graphql-advanced/graphql-subscription/src/main/java/io/backend/skeleton/graphql/advanced/subscription/GraphQlSubscriptionDispatcher.java` +- Create: `modules/graphql-advanced/graphql-subscription/src/main/java/io/backend/skeleton/graphql/advanced/subscription/GraphQlSubscriptionTermination.java` +- Test: `modules/graphql-advanced/graphql-subscription/src/test/java/io/backend/skeleton/graphql/advanced/subscription/GraphQlSubscriptionBufferPolicyTest.java` + +**Interfaces:** +- Consumes: Messaging/Application Publisher source, actor context, Reactor Publisher와 WebSocket/SSE transport. +- Produces: 내구성 보장을 과장하지 않는 bounded live stream과 slow-consumer termination. + +**Implementation requirements:** +- Messaging ACK·offset·DLQ·replay를 GraphQL subscription이 재구현하지 않는다. +- Default slow-consumer 정책은 silent drop이 아니라 connection/operation 종료다. +- Maximum buffered events와 maximum event bytes를 설정한다. +- Subscriber cancel이 upstream source에 전파된다. +- Integration Event를 GraphQL DTO로 변환하고 원본 broker message를 그대로 노출하지 않는다. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlSubscriptionBufferPolicyTest { + @org.junit.jupiter.api.Test + void defaultPolicyTerminatesSlowConsumer() { + var policy = GraphQlSubscriptionBufferPolicy.defaultPolicy(); + + org.assertj.core.api.Assertions.assertThat( + policy.slowConsumerPolicy()) + .isEqualTo( + GraphQlSlowConsumerPolicy.TERMINATE); + org.assertj.core.api.Assertions.assertThat( + policy.maximumBufferedEvents()).isGreaterThan(0); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql-advanced:graphql-subscription:test --tests 'io.backend.skeleton.graphql.advanced.subscription.GraphQlSubscriptionBufferPolicyTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public enum GraphQlSlowConsumerPolicy { + TERMINATE, + DROP_ALLOWED_FOR_TELEMETRY +} + +public record GraphQlSubscriptionBufferPolicy( + int maximumBufferedEvents, + long maximumBufferedBytes, + GraphQlSlowConsumerPolicy slowConsumerPolicy) { + + public static GraphQlSubscriptionBufferPolicy defaultPolicy() { + return new GraphQlSubscriptionBufferPolicy( + 128, 1_048_576, + GraphQlSlowConsumerPolicy.TERMINATE); + } +} + +public interface GraphQlSubscriptionSource { + org.reactivestreams.Publisher open( + GraphQlSubscriptionContext context); +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql-advanced:graphql-subscription:test --tests 'io.backend.skeleton.graphql.advanced.subscription.GraphQlSubscriptionBufferPolicyTest' +./gradlew graphqlAdvancedTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql-advanced/graphql-subscription/src/main/java/io/backend/skeleton/graphql/advanced/subscription/GraphQlSubscriptionSource.java' 'modules/graphql-advanced/graphql-subscription/src/main/java/io/backend/skeleton/graphql/advanced/subscription/GraphQlSubscriptionEvent.java' 'modules/graphql-advanced/graphql-subscription/src/main/java/io/backend/skeleton/graphql/advanced/subscription/GraphQlSubscriptionContext.java' 'modules/graphql-advanced/graphql-subscription/src/main/java/io/backend/skeleton/graphql/advanced/subscription/GraphQlSubscriptionBufferPolicy.java' 'modules/graphql-advanced/graphql-subscription/src/main/java/io/backend/skeleton/graphql/advanced/subscription/GraphQlSlowConsumerPolicy.java' 'modules/graphql-advanced/graphql-subscription/src/main/java/io/backend/skeleton/graphql/advanced/subscription/GraphQlSubscriptionDispatcher.java' 'modules/graphql-advanced/graphql-subscription/src/main/java/io/backend/skeleton/graphql/advanced/subscription/GraphQlSubscriptionTermination.java' 'modules/graphql-advanced/graphql-subscription/src/test/java/io/backend/skeleton/graphql/advanced/subscription/GraphQlSubscriptionBufferPolicyTest.java' +git commit -m "feat: add bounded graphql subscriptions" +``` + +### Task 8: Subscription Ordering·Cancellation·Shutdown Profiles + +**Files:** +- Create: `modules/graphql-advanced/graphql-subscription/src/main/java/io/backend/skeleton/graphql/advanced/subscription/GraphQlSubscriptionOrderingProfile.java` +- Create: `modules/graphql-advanced/graphql-subscription/src/main/java/io/backend/skeleton/graphql/advanced/subscription/GraphQlSubscriptionExecutionPolicy.java` +- Create: `modules/graphql-advanced/graphql-subscription/src/main/java/io/backend/skeleton/graphql/advanced/subscription/GraphQlSubscriptionCancellation.java` +- Create: `modules/graphql-advanced/graphql-subscription/src/main/java/io/backend/skeleton/graphql/advanced/subscription/GraphQlSubscriptionDrainCoordinator.java` +- Create: `modules/graphql-advanced/graphql-subscription/src/main/java/io/backend/skeleton/graphql/advanced/subscription/GraphQlSubscriptionState.java` +- Create: `modules/graphql-advanced/graphql-subscription/src/main/java/io/backend/skeleton/graphql/advanced/subscription/GraphQlSubscriptionMetrics.java` +- Test: `modules/graphql-advanced/graphql-subscription/src/test/java/io/backend/skeleton/graphql/advanced/subscription/GraphQlSubscriptionExecutionPolicyTest.java` + +**Interfaces:** +- Consumes: Subscription source, GraphQL Java subscription execution strategy와 transport lifecycle. +- Produces: `LOW_LATENCY`·`ORDERED` profile, cancellation과 graceful shutdown drain. + +**Implementation requirements:** +- `ORDERED`는 source order를 보존하지만 head-of-line blocking과 buffer 비용을 명시한다. +- `LOW_LATENCY`는 completion 순서가 source 순서와 다를 수 있음을 계약한다. +- Ordering profile은 operation catalog에 등록한다. +- Shutdown에서 신규 subscription을 거부하고 bounded time 동안 기존 subscription을 drain한다. +- Cancellation은 resolver nested publisher와 upstream source 모두에 전파한다. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlSubscriptionExecutionPolicyTest { + @org.junit.jupiter.api.Test + void orderedProfileRequestsGraphQlJavaOrderingFlag() { + var policy = GraphQlSubscriptionExecutionPolicy.ordered(); + + org.assertj.core.api.Assertions.assertThat( + policy.profile()) + .isEqualTo( + GraphQlSubscriptionOrderingProfile.ORDERED); + org.assertj.core.api.Assertions.assertThat( + policy.keepSourceOrder()).isTrue(); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql-advanced:graphql-subscription:test --tests 'io.backend.skeleton.graphql.advanced.subscription.GraphQlSubscriptionExecutionPolicyTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public enum GraphQlSubscriptionOrderingProfile { + LOW_LATENCY, + ORDERED +} + +public record GraphQlSubscriptionExecutionPolicy( + GraphQlSubscriptionOrderingProfile profile, + boolean keepSourceOrder) { + + public static GraphQlSubscriptionExecutionPolicy ordered() { + return new GraphQlSubscriptionExecutionPolicy( + GraphQlSubscriptionOrderingProfile.ORDERED, true); + } + + public static GraphQlSubscriptionExecutionPolicy lowLatency() { + return new GraphQlSubscriptionExecutionPolicy( + GraphQlSubscriptionOrderingProfile.LOW_LATENCY, + false); + } +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql-advanced:graphql-subscription:test --tests 'io.backend.skeleton.graphql.advanced.subscription.GraphQlSubscriptionExecutionPolicyTest' +./gradlew graphqlAdvancedTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql-advanced/graphql-subscription/src/main/java/io/backend/skeleton/graphql/advanced/subscription/GraphQlSubscriptionOrderingProfile.java' 'modules/graphql-advanced/graphql-subscription/src/main/java/io/backend/skeleton/graphql/advanced/subscription/GraphQlSubscriptionExecutionPolicy.java' 'modules/graphql-advanced/graphql-subscription/src/main/java/io/backend/skeleton/graphql/advanced/subscription/GraphQlSubscriptionCancellation.java' 'modules/graphql-advanced/graphql-subscription/src/main/java/io/backend/skeleton/graphql/advanced/subscription/GraphQlSubscriptionDrainCoordinator.java' 'modules/graphql-advanced/graphql-subscription/src/main/java/io/backend/skeleton/graphql/advanced/subscription/GraphQlSubscriptionState.java' 'modules/graphql-advanced/graphql-subscription/src/main/java/io/backend/skeleton/graphql/advanced/subscription/GraphQlSubscriptionMetrics.java' 'modules/graphql-advanced/graphql-subscription/src/test/java/io/backend/skeleton/graphql/advanced/subscription/GraphQlSubscriptionExecutionPolicyTest.java' +git commit -m "feat: add graphql subscription ordering profiles" +``` + +### Task 9: SSE Distinct Connection Subscription Adapter + +**Files:** +- Create: `modules/graphql-advanced/graphql-sse/src/main/java/io/backend/skeleton/graphql/advanced/sse/GraphQlSseProperties.java` +- Create: `modules/graphql-advanced/graphql-sse/src/main/java/io/backend/skeleton/graphql/advanced/sse/GraphQlSseHandlerFactory.java` +- Create: `modules/graphql-advanced/graphql-sse/src/main/java/io/backend/skeleton/graphql/advanced/sse/GraphQlSseConnectionPolicy.java` +- Create: `modules/graphql-advanced/graphql-sse/src/main/java/io/backend/skeleton/graphql/advanced/sse/GraphQlSseHeartbeat.java` +- Create: `modules/graphql-advanced/graphql-sse/src/main/java/io/backend/skeleton/graphql/advanced/sse/GraphQlSseTermination.java` +- Test: `modules/graphql-advanced/graphql-sse/src/test/java/io/backend/skeleton/graphql/advanced/sse/GraphQlSseConnectionPolicyTest.java` + +**Interfaces:** +- Consumes: Spring `GraphQlSseHandler`, Stable execution service, subscription buffer and auth policy. +- Produces: POST JSON + `Accept: text/event-stream` 기반 Distinct Connection subscription transport. + +**Implementation requirements:** +- SSE를 Query·Mutation의 일반 response mode로 사용하지 않고 subscription-only로 제한한다. +- Connection당 하나의 subscription이라는 Distinct Connection 특성을 문서·metric에 반영한다. +- Heartbeat, idle timeout, maximum duration과 proxy buffering 요구를 설정한다. +- WebSocket과 동일한 actor·tenant·authorization·cost policy를 사용한다. +- HTTP/2 사용 여부와 연결 규모를 load test로 검증한다. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlSseConnectionPolicyTest { + @org.junit.jupiter.api.Test + void acceptsOnlySubscriptionOperation() { + var policy = GraphQlSseConnectionPolicy.standard(); + + org.assertj.core.api.Assertions.assertThat( + policy.supports("subscription")).isTrue(); + org.assertj.core.api.Assertions.assertThat( + policy.supports("query")).isFalse(); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql-advanced:graphql-sse:test --tests 'io.backend.skeleton.graphql.advanced.sse.GraphQlSseConnectionPolicyTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public final class GraphQlSseConnectionPolicy { + public static GraphQlSseConnectionPolicy standard() { + return new GraphQlSseConnectionPolicy(); + } + + public boolean supports(String operationType) { + return "subscription".equals(operationType); + } +} + +public record GraphQlSseProperties( + java.time.Duration heartbeatInterval, + java.time.Duration idleTimeout, + java.time.Duration maximumDuration) { +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql-advanced:graphql-sse:test --tests 'io.backend.skeleton.graphql.advanced.sse.GraphQlSseConnectionPolicyTest' +./gradlew graphqlAdvancedTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql-advanced/graphql-sse/src/main/java/io/backend/skeleton/graphql/advanced/sse/GraphQlSseProperties.java' 'modules/graphql-advanced/graphql-sse/src/main/java/io/backend/skeleton/graphql/advanced/sse/GraphQlSseHandlerFactory.java' 'modules/graphql-advanced/graphql-sse/src/main/java/io/backend/skeleton/graphql/advanced/sse/GraphQlSseConnectionPolicy.java' 'modules/graphql-advanced/graphql-sse/src/main/java/io/backend/skeleton/graphql/advanced/sse/GraphQlSseHeartbeat.java' 'modules/graphql-advanced/graphql-sse/src/main/java/io/backend/skeleton/graphql/advanced/sse/GraphQlSseTermination.java' 'modules/graphql-advanced/graphql-sse/src/test/java/io/backend/skeleton/graphql/advanced/sse/GraphQlSseConnectionPolicyTest.java' +git commit -m "feat: add graphql sse subscription transport" +``` + +### Task 10: Messaging-backed Subscription Replay Extension + +**Files:** +- Create: `modules/graphql-advanced/graphql-subscription/src/main/java/io/backend/skeleton/graphql/advanced/replay/GraphQlSubscriptionCursor.java` +- Create: `modules/graphql-advanced/graphql-subscription/src/main/java/io/backend/skeleton/graphql/advanced/replay/GraphQlReplayPosition.java` +- Create: `modules/graphql-advanced/graphql-subscription/src/main/java/io/backend/skeleton/graphql/advanced/replay/GraphQlReplaySource.java` +- Create: `modules/graphql-advanced/graphql-subscription/src/main/java/io/backend/skeleton/graphql/advanced/replay/GraphQlSnapshotLiveHandoff.java` +- Create: `modules/graphql-advanced/graphql-subscription/src/main/java/io/backend/skeleton/graphql/advanced/replay/GraphQlReplayAuthorization.java` +- Create: `modules/graphql-advanced/graphql-subscription/src/main/java/io/backend/skeleton/graphql/advanced/replay/GraphQlReplayGapException.java` +- Test: `modules/graphql-advanced/graphql-subscription/src/test/java/io/backend/skeleton/graphql/advanced/replay/GraphQlSnapshotLiveHandoffTest.java` + +**Interfaces:** +- Consumes: Messaging replay/offset capability, snapshot query, actor authorization와 signed cursor. +- Produces: GraphQL 표준 보장과 분리된 snapshot + live handoff 및 explicit replay cursor extension. + +**Implementation requirements:** +- GraphQL Core에 resume 표준이 있다고 광고하지 않는다. +- Replay durability와 offset 보장은 Messaging platform이 소유한다. +- Snapshot sequence와 live source 시작 position 사이에 gap·duplicate가 없도록 handoff를 검증한다. +- Cursor는 actor/client·subscription profile에 bind하고 서명한다. +- Replay window 밖의 cursor는 history-lost error로 반환한다. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlSnapshotLiveHandoffTest { + @org.junit.jupiter.api.Test + void gapBetweenSnapshotAndLivePositionIsRejected() { + var handoff = new GraphQlSnapshotLiveHandoff( + new GraphQlReplayPosition(10), + new GraphQlReplayPosition(12)); + + org.assertj.core.api.Assertions.assertThatThrownBy( + handoff::verifyContiguous) + .isInstanceOf(GraphQlReplayGapException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql-advanced:graphql-subscription:test --tests 'io.backend.skeleton.graphql.advanced.replay.GraphQlSnapshotLiveHandoffTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GraphQlReplayPosition(long sequence) { + public GraphQlReplayPosition { + if (sequence < 0) { + throw new IllegalArgumentException( + "sequence cannot be negative"); + } + } +} + +public record GraphQlSnapshotLiveHandoff( + GraphQlReplayPosition snapshotPosition, + GraphQlReplayPosition liveStartPosition) { + + public void verifyContiguous() { + if (liveStartPosition.sequence() + > snapshotPosition.sequence() + 1) { + throw new GraphQlReplayGapException( + "snapshot and live stream contain a gap"); + } + } +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql-advanced:graphql-subscription:test --tests 'io.backend.skeleton.graphql.advanced.replay.GraphQlSnapshotLiveHandoffTest' +./gradlew graphqlAdvancedTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql-advanced/graphql-subscription/src/main/java/io/backend/skeleton/graphql/advanced/replay/GraphQlSubscriptionCursor.java' 'modules/graphql-advanced/graphql-subscription/src/main/java/io/backend/skeleton/graphql/advanced/replay/GraphQlReplayPosition.java' 'modules/graphql-advanced/graphql-subscription/src/main/java/io/backend/skeleton/graphql/advanced/replay/GraphQlReplaySource.java' 'modules/graphql-advanced/graphql-subscription/src/main/java/io/backend/skeleton/graphql/advanced/replay/GraphQlSnapshotLiveHandoff.java' 'modules/graphql-advanced/graphql-subscription/src/main/java/io/backend/skeleton/graphql/advanced/replay/GraphQlReplayAuthorization.java' 'modules/graphql-advanced/graphql-subscription/src/main/java/io/backend/skeleton/graphql/advanced/replay/GraphQlReplayGapException.java' 'modules/graphql-advanced/graphql-subscription/src/test/java/io/backend/skeleton/graphql/advanced/replay/GraphQlSnapshotLiveHandoffTest.java' +git commit -m "feat: add graphql subscription replay extension" +``` + +### Task 11: GraphQL Java 25 Chained DataLoader Opt-in + +**Files:** +- Create: `modules/graphql-advanced/graphql-dataloader-chaining/src/main/java/io/backend/skeleton/graphql/advanced/chaining/GraphQlChainedDataLoaderPolicy.java` +- Create: `modules/graphql-advanced/graphql-dataloader-chaining/src/main/java/io/backend/skeleton/graphql/advanced/chaining/GraphQlDataLoaderDependencyGraph.java` +- Create: `modules/graphql-advanced/graphql-dataloader-chaining/src/main/java/io/backend/skeleton/graphql/advanced/chaining/GraphQlDataLoaderCycleDetector.java` +- Create: `modules/graphql-advanced/graphql-dataloader-chaining/src/main/java/io/backend/skeleton/graphql/advanced/chaining/GraphQlChainedDispatchConfigurer.java` +- Create: `modules/graphql-advanced/graphql-dataloader-chaining/src/main/java/io/backend/skeleton/graphql/advanced/chaining/GraphQlChainedLoaderMetrics.java` +- Test: `modules/graphql-advanced/graphql-dataloader-chaining/src/test/java/io/backend/skeleton/graphql/advanced/chaining/GraphQlDataLoaderCycleDetectorTest.java` + +**Interfaces:** +- Consumes: Stable request-scoped DataLoader registry와 GraphQL Java 25 chained dispatch capability. +- Produces: 명시적 dependency graph를 가진 opt-in chained loader execution과 cycle·dispatch regression gate. + +**Implementation requirements:** +- Stable default에서는 chained dispatch를 활성화하지 않는다. +- 각 loader dependency를 manifest에 선언한다. +- Dependency cycle이 있으면 startup 실패다. +- Dispatch 순서 변경이 query count·batch size·result ordering에 미치는 영향을 contract test로 검증한다. +- Request scope, actor·tenant isolation과 maximum batch size 규칙을 그대로 유지한다. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlDataLoaderCycleDetectorTest { + @org.junit.jupiter.api.Test + void detectsLoaderDependencyCycle() { + var graph = new GraphQlDataLoaderDependencyGraph() + .dependsOn("orders", "customers") + .dependsOn("customers", "orders"); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> new GraphQlDataLoaderCycleDetector().verify(graph)) + .isInstanceOf( + GraphQlDataLoaderDependencyCycleException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql-advanced:graphql-dataloader-chaining:test --tests 'io.backend.skeleton.graphql.advanced.chaining.GraphQlDataLoaderCycleDetectorTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public final class GraphQlDataLoaderDependencyGraph { + private final java.util.Map> edges = + new java.util.LinkedHashMap<>(); + + public GraphQlDataLoaderDependencyGraph dependsOn( + String loader, String dependency) { + edges.computeIfAbsent(loader, + ignored -> new java.util.LinkedHashSet<>()) + .add(dependency); + return this; + } + + java.util.Map> edges() { + return java.util.Collections.unmodifiableMap(edges); + } +} + +public record GraphQlChainedDataLoaderPolicy( + boolean enabled, + int maximumDepth) { +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql-advanced:graphql-dataloader-chaining:test --tests 'io.backend.skeleton.graphql.advanced.chaining.GraphQlDataLoaderCycleDetectorTest' +./gradlew graphqlAdvancedTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql-advanced/graphql-dataloader-chaining/src/main/java/io/backend/skeleton/graphql/advanced/chaining/GraphQlChainedDataLoaderPolicy.java' 'modules/graphql-advanced/graphql-dataloader-chaining/src/main/java/io/backend/skeleton/graphql/advanced/chaining/GraphQlDataLoaderDependencyGraph.java' 'modules/graphql-advanced/graphql-dataloader-chaining/src/main/java/io/backend/skeleton/graphql/advanced/chaining/GraphQlDataLoaderCycleDetector.java' 'modules/graphql-advanced/graphql-dataloader-chaining/src/main/java/io/backend/skeleton/graphql/advanced/chaining/GraphQlChainedDispatchConfigurer.java' 'modules/graphql-advanced/graphql-dataloader-chaining/src/main/java/io/backend/skeleton/graphql/advanced/chaining/GraphQlChainedLoaderMetrics.java' 'modules/graphql-advanced/graphql-dataloader-chaining/src/test/java/io/backend/skeleton/graphql/advanced/chaining/GraphQlDataLoaderCycleDetectorTest.java' +git commit -m "feat: add opt in chained graphql dataloaders" +``` + +### Task 12: Federation Subgraph Schema와 Entity Resolver + +**Files:** +- Create: `modules/graphql-advanced/graphql-federation/src/main/java/io/backend/skeleton/graphql/advanced/federation/GraphQlFederationProperties.java` +- Create: `modules/graphql-advanced/graphql-federation/src/main/java/io/backend/skeleton/graphql/advanced/federation/GraphQlFederationSchemaFactory.java` +- Create: `modules/graphql-advanced/graphql-federation/src/main/java/io/backend/skeleton/graphql/advanced/federation/GraphQlFederationEntityKey.java` +- Create: `modules/graphql-advanced/graphql-federation/src/main/java/io/backend/skeleton/graphql/advanced/federation/GraphQlFederationEntityResolver.java` +- Create: `modules/graphql-advanced/graphql-federation/src/main/java/io/backend/skeleton/graphql/advanced/federation/GraphQlFederationBatchResolver.java` +- Create: `modules/graphql-advanced/graphql-federation/src/main/java/io/backend/skeleton/graphql/advanced/federation/GraphQlFederationCapability.java` +- Test: `modules/graphql-advanced/graphql-federation/src/test/java/io/backend/skeleton/graphql/advanced/federation/GraphQlFederationEntityResolverTest.java` + +**Interfaces:** +- Consumes: Stable SDL assembly, federation-jvm integration, Application query services와 DataLoader. +- Produces: 별도 opt-in Subgraph schema, `@key` entity mapping과 batch reference resolution. + +**Implementation requirements:** +- Single executable schema가 기본이며 federation flag가 없으면 federation wiring을 등록하지 않는다. +- Entity reference resolver가 Repository를 직접 호출하지 않고 Application query service를 사용한다. +- Entity key는 versioned contract이며 변경 시 breaking review를 요구한다. +- Batch entity resolution은 요청 단위 DataLoader와 tenant context를 사용한다. +- Federation router나 supergraph 운영은 이 모듈이 소유하지 않는다. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlFederationEntityResolverTest { + @org.junit.jupiter.api.Test + void rejectsRepresentationMissingDeclaredKey() { + var key = new GraphQlFederationEntityKey( + "Order", java.util.List.of("id")); + var resolver = new GraphQlFederationEntityResolver(key); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> resolver.validateRepresentation( + java.util.Map.of("__typename", "Order"))) + .isInstanceOf( + GraphQlFederationRepresentationException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql-advanced:graphql-federation:test --tests 'io.backend.skeleton.graphql.advanced.federation.GraphQlFederationEntityResolverTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GraphQlFederationEntityKey( + String typeName, + java.util.List fields) { + public GraphQlFederationEntityKey { + fields = java.util.List.copyOf(fields); + if (fields.isEmpty()) { + throw new IllegalArgumentException( + "federation entity key cannot be empty"); + } + } +} + +public final class GraphQlFederationEntityResolver { + private final GraphQlFederationEntityKey key; + + public GraphQlFederationEntityResolver( + GraphQlFederationEntityKey key) { + this.key = key; + } + + public void validateRepresentation( + java.util.Map representation) { + for (String field : key.fields()) { + if (!representation.containsKey(field)) { + throw new + GraphQlFederationRepresentationException( + "missing federation entity key field"); + } + } + } +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql-advanced:graphql-federation:test --tests 'io.backend.skeleton.graphql.advanced.federation.GraphQlFederationEntityResolverTest' +./gradlew graphqlAdvancedTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql-advanced/graphql-federation/src/main/java/io/backend/skeleton/graphql/advanced/federation/GraphQlFederationProperties.java' 'modules/graphql-advanced/graphql-federation/src/main/java/io/backend/skeleton/graphql/advanced/federation/GraphQlFederationSchemaFactory.java' 'modules/graphql-advanced/graphql-federation/src/main/java/io/backend/skeleton/graphql/advanced/federation/GraphQlFederationEntityKey.java' 'modules/graphql-advanced/graphql-federation/src/main/java/io/backend/skeleton/graphql/advanced/federation/GraphQlFederationEntityResolver.java' 'modules/graphql-advanced/graphql-federation/src/main/java/io/backend/skeleton/graphql/advanced/federation/GraphQlFederationBatchResolver.java' 'modules/graphql-advanced/graphql-federation/src/main/java/io/backend/skeleton/graphql/advanced/federation/GraphQlFederationCapability.java' 'modules/graphql-advanced/graphql-federation/src/test/java/io/backend/skeleton/graphql/advanced/federation/GraphQlFederationEntityResolverTest.java' +git commit -m "feat: add graphql federation subgraph" +``` + +### Task 13: Federation Composition·Deployment·Failure Gate + +**Files:** +- Create: `modules/graphql-advanced/graphql-federation/src/main/java/io/backend/skeleton/graphql/advanced/composition/GraphQlSubgraphContract.java` +- Create: `modules/graphql-advanced/graphql-federation/src/main/java/io/backend/skeleton/graphql/advanced/composition/GraphQlFederationCompositionResult.java` +- Create: `modules/graphql-advanced/graphql-federation/src/main/java/io/backend/skeleton/graphql/advanced/composition/GraphQlFederationCompositionGate.java` +- Create: `modules/graphql-advanced/graphql-federation/src/main/java/io/backend/skeleton/graphql/advanced/composition/GraphQlFederationUsageReport.java` +- Create: `modules/graphql-advanced/graphql-federation/src/main/java/io/backend/skeleton/graphql/advanced/composition/GraphQlFederationLatencyBudget.java` +- Create: `modules/graphql-advanced/graphql-federation/src/main/java/io/backend/skeleton/graphql/advanced/composition/GraphQlFederationDeploymentOrder.java` +- Create: `modules/graphql-advanced/graphql-federation/src/main/java/io/backend/skeleton/graphql/advanced/composition/GraphQlFederationReleaseEvidence.java` +- Test: `modules/graphql-advanced/graphql-federation/src/test/java/io/backend/skeleton/graphql/advanced/composition/GraphQlFederationCompositionGateTest.java` + +**Interfaces:** +- Consumes: Subgraph SDL, entity keys, router composition result, usage·latency·failure evidence. +- Produces: Composition CI, cross-subgraph dependency·latency·partial failure 검증과 배포 순서 gate. + +**Implementation requirements:** +- Composition 성공만으로 release를 승인하지 않고 entity key, owner, downstream dependency, latency budget을 검증한다. +- Cross-subgraph N+1과 per-entity downstream call을 performance gate에서 탐지한다. +- Subgraph schema가 router보다 먼저 또는 호환되지 않는 순서로 배포되지 않도록 deployment order를 검증한다. +- 부분 장애에서 nullable boundary와 error ownership을 contract test로 검증한다. +- Router 설정과 운영은 별도 프로젝트의 evidence로 입력받는다. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlFederationCompositionGateTest { + @org.junit.jupiter.api.Test + void compositionWithoutLatencyEvidenceIsRejected() { + var evidence = new GraphQlFederationReleaseEvidence( + true, true, false, true); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> new GraphQlFederationCompositionGate() + .verify(evidence)) + .isInstanceOf( + GraphQlFederationReleaseRejectedException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql-advanced:graphql-federation:test --tests 'io.backend.skeleton.graphql.advanced.composition.GraphQlFederationCompositionGateTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GraphQlFederationReleaseEvidence( + boolean compositionPassed, + boolean entityContractsPassed, + boolean latencyPassed, + boolean failureContractsPassed) { +} + +public final class GraphQlFederationCompositionGate { + public void verify( + GraphQlFederationReleaseEvidence evidence) { + if (!evidence.compositionPassed() + || !evidence.entityContractsPassed() + || !evidence.latencyPassed() + || !evidence.failureContractsPassed()) { + throw new + GraphQlFederationReleaseRejectedException( + "federation composition evidence incomplete"); + } + } +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql-advanced:graphql-federation:test --tests 'io.backend.skeleton.graphql.advanced.composition.GraphQlFederationCompositionGateTest' +./gradlew graphqlAdvancedTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql-advanced/graphql-federation/src/main/java/io/backend/skeleton/graphql/advanced/composition/GraphQlSubgraphContract.java' 'modules/graphql-advanced/graphql-federation/src/main/java/io/backend/skeleton/graphql/advanced/composition/GraphQlFederationCompositionResult.java' 'modules/graphql-advanced/graphql-federation/src/main/java/io/backend/skeleton/graphql/advanced/composition/GraphQlFederationCompositionGate.java' 'modules/graphql-advanced/graphql-federation/src/main/java/io/backend/skeleton/graphql/advanced/composition/GraphQlFederationUsageReport.java' 'modules/graphql-advanced/graphql-federation/src/main/java/io/backend/skeleton/graphql/advanced/composition/GraphQlFederationLatencyBudget.java' 'modules/graphql-advanced/graphql-federation/src/main/java/io/backend/skeleton/graphql/advanced/composition/GraphQlFederationDeploymentOrder.java' 'modules/graphql-advanced/graphql-federation/src/main/java/io/backend/skeleton/graphql/advanced/composition/GraphQlFederationReleaseEvidence.java' 'modules/graphql-advanced/graphql-federation/src/test/java/io/backend/skeleton/graphql/advanced/composition/GraphQlFederationCompositionGateTest.java' +git commit -m "chore: add graphql federation composition gate" +``` + +### Task 14: Client·Transport DTO Code Generation + +**Files:** +- Create: `modules/graphql-advanced/graphql-codegen/src/main/java/io/backend/skeleton/graphql/advanced/codegen/GraphQlCodegenProfile.java` +- Create: `modules/graphql-advanced/graphql-codegen/src/main/java/io/backend/skeleton/graphql/advanced/codegen/GraphQlScalarMapping.java` +- Create: `modules/graphql-advanced/graphql-codegen/src/main/java/io/backend/skeleton/graphql/advanced/codegen/GraphQlGeneratedSourceBoundary.java` +- Create: `modules/graphql-advanced/graphql-codegen/src/main/java/io/backend/skeleton/graphql/advanced/codegen/GraphQlClientOperationGenerator.java` +- Create: `modules/graphql-advanced/graphql-codegen/src/main/java/io/backend/skeleton/graphql/advanced/codegen/GraphQlTransportTypeGenerator.java` +- Create: `modules/graphql-advanced/graphql-codegen/src/main/java/io/backend/skeleton/graphql/advanced/codegen/GraphQlGeneratedCompatibilityGate.java` +- Test: `modules/graphql-advanced/graphql-codegen/src/test/java/io/backend/skeleton/graphql/advanced/codegen/GraphQlGeneratedSourceBoundaryTest.java` + +**Interfaces:** +- Consumes: SDL, operation documents, scalar manifest와 selected codegen engine. +- Produces: Client request/response model과 transport-only type을 생성하되 domain·repository를 생성하지 않는 tooling. + +**Implementation requirements:** +- Domain Entity, Application Use Case interface와 Repository를 생성하지 않는다. +- Generated source는 별도 directory·package에 두고 사람이 작성한 domain code를 덮어쓰지 않는다. +- Custom scalar, nullability, union, interface와 oneOf mapping을 명시한다. +- Operation document를 schema와 함께 compile-time 검증한다. +- Schema additive change가 generated client의 exhaustive enum/union source compatibility를 깨는지 별도 보고한다. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlGeneratedSourceBoundaryTest { + @org.junit.jupiter.api.Test + void domainAndRepositoryGenerationAreForbidden() { + var boundary = GraphQlGeneratedSourceBoundary.standard(); + + org.assertj.core.api.Assertions.assertThat( + boundary.isAllowed("CLIENT_RESPONSE")).isTrue(); + org.assertj.core.api.Assertions.assertThat( + boundary.isAllowed("DOMAIN_ENTITY")).isFalse(); + org.assertj.core.api.Assertions.assertThat( + boundary.isAllowed("REPOSITORY")).isFalse(); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql-advanced:graphql-codegen:test --tests 'io.backend.skeleton.graphql.advanced.codegen.GraphQlGeneratedSourceBoundaryTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public final class GraphQlGeneratedSourceBoundary { + private static final java.util.Set ALLOWED = + java.util.Set.of( + "CLIENT_REQUEST", + "CLIENT_RESPONSE", + "TRANSPORT_INPUT", + "TRANSPORT_OUTPUT"); + + public static GraphQlGeneratedSourceBoundary standard() { + return new GraphQlGeneratedSourceBoundary(); + } + + public boolean isAllowed(String generatedType) { + return ALLOWED.contains(generatedType); + } +} + +public record GraphQlScalarMapping( + String scalarName, + String javaType, + String codecId) { +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql-advanced:graphql-codegen:test --tests 'io.backend.skeleton.graphql.advanced.codegen.GraphQlGeneratedSourceBoundaryTest' +./gradlew graphqlAdvancedTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql-advanced/graphql-codegen/src/main/java/io/backend/skeleton/graphql/advanced/codegen/GraphQlCodegenProfile.java' 'modules/graphql-advanced/graphql-codegen/src/main/java/io/backend/skeleton/graphql/advanced/codegen/GraphQlScalarMapping.java' 'modules/graphql-advanced/graphql-codegen/src/main/java/io/backend/skeleton/graphql/advanced/codegen/GraphQlGeneratedSourceBoundary.java' 'modules/graphql-advanced/graphql-codegen/src/main/java/io/backend/skeleton/graphql/advanced/codegen/GraphQlClientOperationGenerator.java' 'modules/graphql-advanced/graphql-codegen/src/main/java/io/backend/skeleton/graphql/advanced/codegen/GraphQlTransportTypeGenerator.java' 'modules/graphql-advanced/graphql-codegen/src/main/java/io/backend/skeleton/graphql/advanced/codegen/GraphQlGeneratedCompatibilityGate.java' 'modules/graphql-advanced/graphql-codegen/src/test/java/io/backend/skeleton/graphql/advanced/codegen/GraphQlGeneratedSourceBoundaryTest.java' +git commit -m "build: add graphql client code generation" +``` + +### Task 15: Allowlisted Spring Data GraphQL Compatibility + +**Files:** +- Create: `modules/graphql-advanced/graphql-spring-data-compat/src/main/java/io/backend/skeleton/graphql/advanced/compat/GraphQlRepositoryExposure.java` +- Create: `modules/graphql-advanced/graphql-spring-data-compat/src/main/java/io/backend/skeleton/graphql/advanced/compat/GraphQlRepositoryAllowlist.java` +- Create: `modules/graphql-advanced/graphql-spring-data-compat/src/main/java/io/backend/skeleton/graphql/advanced/compat/GraphQlRepositoryArgumentPolicy.java` +- Create: `modules/graphql-advanced/graphql-spring-data-compat/src/main/java/io/backend/skeleton/graphql/advanced/compat/GraphQlRepositoryPaginationPolicy.java` +- Create: `modules/graphql-advanced/graphql-spring-data-compat/src/main/java/io/backend/skeleton/graphql/advanced/compat/GraphQlRepositoryProjectionPolicy.java` +- Create: `modules/graphql-advanced/graphql-spring-data-compat/src/main/java/io/backend/skeleton/graphql/advanced/compat/GraphQlRepositoryExposureValidator.java` +- Test: `modules/graphql-advanced/graphql-spring-data-compat/src/test/java/io/backend/skeleton/graphql/advanced/compat/GraphQlRepositoryExposureValidatorTest.java` + +**Interfaces:** +- Consumes: Spring `@GraphQlRepository`, Querydsl/QBE repository, schema coordinate와 explicit allowlist. +- Produces: 자동 DataFetcher 기능을 제한된 compatibility path로 제공하고 persistence model 자동 노출을 차단. + +**Implementation requirements:** +- 등록되지 않은 repository는 GraphQL DataFetcher로 자동 노출하지 않는다. +- 허용 filter, sort, projection과 maximum page size를 coordinate별로 정의한다. +- 기본 offset pagination 20개 동작을 암묵적으로 사용하지 않고 explicit policy를 요구한다. +- Entity/Document를 output으로 직접 반환하지 않고 승인된 projection만 허용한다. +- Stable resolver·Application Service 경계를 대체하는 주류 API로 문서화하지 않는다. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlRepositoryExposureValidatorTest { + @org.junit.jupiter.api.Test + void unregisteredRepositoryIsRejected() { + var validator = + new GraphQlRepositoryExposureValidator( + GraphQlRepositoryAllowlist.empty()); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> validator.verify( + new GraphQlRepositoryExposure( + "OrderRepository", "Query.orders"))) + .isInstanceOf( + GraphQlRepositoryExposureRejectedException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql-advanced:graphql-spring-data-compat:test --tests 'io.backend.skeleton.graphql.advanced.compat.GraphQlRepositoryExposureValidatorTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GraphQlRepositoryExposure( + String repositoryName, + String schemaCoordinate) { +} + +public final class GraphQlRepositoryAllowlist { + private final java.util.Set repositoryNames; + + private GraphQlRepositoryAllowlist( + java.util.Set repositoryNames) { + this.repositoryNames = + java.util.Set.copyOf(repositoryNames); + } + + public static GraphQlRepositoryAllowlist empty() { + return new GraphQlRepositoryAllowlist( + java.util.Set.of()); + } + + public boolean contains(String repositoryName) { + return repositoryNames.contains(repositoryName); + } +} + +public final class GraphQlRepositoryExposureValidator { + private final GraphQlRepositoryAllowlist allowlist; + + public GraphQlRepositoryExposureValidator( + GraphQlRepositoryAllowlist allowlist) { + this.allowlist = allowlist; + } + + public void verify(GraphQlRepositoryExposure exposure) { + if (!allowlist.contains(exposure.repositoryName())) { + throw new + GraphQlRepositoryExposureRejectedException( + exposure.repositoryName()); + } + } +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql-advanced:graphql-spring-data-compat:test --tests 'io.backend.skeleton.graphql.advanced.compat.GraphQlRepositoryExposureValidatorTest' +./gradlew graphqlAdvancedTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql-advanced/graphql-spring-data-compat/src/main/java/io/backend/skeleton/graphql/advanced/compat/GraphQlRepositoryExposure.java' 'modules/graphql-advanced/graphql-spring-data-compat/src/main/java/io/backend/skeleton/graphql/advanced/compat/GraphQlRepositoryAllowlist.java' 'modules/graphql-advanced/graphql-spring-data-compat/src/main/java/io/backend/skeleton/graphql/advanced/compat/GraphQlRepositoryArgumentPolicy.java' 'modules/graphql-advanced/graphql-spring-data-compat/src/main/java/io/backend/skeleton/graphql/advanced/compat/GraphQlRepositoryPaginationPolicy.java' 'modules/graphql-advanced/graphql-spring-data-compat/src/main/java/io/backend/skeleton/graphql/advanced/compat/GraphQlRepositoryProjectionPolicy.java' 'modules/graphql-advanced/graphql-spring-data-compat/src/main/java/io/backend/skeleton/graphql/advanced/compat/GraphQlRepositoryExposureValidator.java' 'modules/graphql-advanced/graphql-spring-data-compat/src/test/java/io/backend/skeleton/graphql/advanced/compat/GraphQlRepositoryExposureValidatorTest.java' +git commit -m "feat: add graphql spring data compat guard" +``` + +### Task 16: RSocket GraphQL Experimental Transport + +**Files:** +- Create: `modules/graphql-advanced/graphql-rsocket/src/main/java/io/backend/skeleton/graphql/advanced/rsocket/GraphQlRSocketProperties.java` +- Create: `modules/graphql-advanced/graphql-rsocket/src/main/java/io/backend/skeleton/graphql/advanced/rsocket/GraphQlRSocketRoutePolicy.java` +- Create: `modules/graphql-advanced/graphql-rsocket/src/main/java/io/backend/skeleton/graphql/advanced/rsocket/GraphQlRSocketHandlerFactory.java` +- Create: `modules/graphql-advanced/graphql-rsocket/src/main/java/io/backend/skeleton/graphql/advanced/rsocket/GraphQlRSocketAuthentication.java` +- Create: `modules/graphql-advanced/graphql-rsocket/src/main/java/io/backend/skeleton/graphql/advanced/rsocket/GraphQlRSocketErrorMapper.java` +- Create: `modules/graphql-advanced/graphql-rsocket/src/main/java/io/backend/skeleton/graphql/advanced/rsocket/GraphQlRSocketCapability.java` +- Test: `modules/graphql-advanced/graphql-rsocket/src/test/java/io/backend/skeleton/graphql/advanced/rsocket/GraphQlRSocketRoutePolicyTest.java` + +**Interfaces:** +- Consumes: Spring GraphQL RSocket handler, Stable execution service와 experimental feature approval. +- Produces: 내부 시스템용 request-response Query/Mutation과 request-stream Subscription experimental transport. + +**Implementation requirements:** +- RSocket는 public default transport가 아니다. +- Query·Mutation은 request-response, Subscription은 request-stream으로만 매핑한다. +- Stable HTTP/WebSocket과 동일한 actor·tenant·cost·error policy를 적용한다. +- Route·metadata MIME type·authentication을 allowlist한다. +- Production 활성화에는 explicit consumer, load·failure evidence와 owner가 필요하다. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlRSocketRoutePolicyTest { + @org.junit.jupiter.api.Test + void rejectsUnknownRoute() { + var policy = new GraphQlRSocketRoutePolicy( + java.util.Set.of("graphql")); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> policy.requireAllowed("admin.raw")) + .isInstanceOf( + GraphQlRSocketRouteRejectedException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql-advanced:graphql-rsocket:test --tests 'io.backend.skeleton.graphql.advanced.rsocket.GraphQlRSocketRoutePolicyTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public final class GraphQlRSocketRoutePolicy { + private final java.util.Set allowedRoutes; + + public GraphQlRSocketRoutePolicy( + java.util.Set allowedRoutes) { + this.allowedRoutes = + java.util.Set.copyOf(allowedRoutes); + } + + public String requireAllowed(String route) { + if (!allowedRoutes.contains(route)) { + throw new GraphQlRSocketRouteRejectedException(route); + } + return route; + } +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql-advanced:graphql-rsocket:test --tests 'io.backend.skeleton.graphql.advanced.rsocket.GraphQlRSocketRoutePolicyTest' +./gradlew graphqlAdvancedTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql-advanced/graphql-rsocket/src/main/java/io/backend/skeleton/graphql/advanced/rsocket/GraphQlRSocketProperties.java' 'modules/graphql-advanced/graphql-rsocket/src/main/java/io/backend/skeleton/graphql/advanced/rsocket/GraphQlRSocketRoutePolicy.java' 'modules/graphql-advanced/graphql-rsocket/src/main/java/io/backend/skeleton/graphql/advanced/rsocket/GraphQlRSocketHandlerFactory.java' 'modules/graphql-advanced/graphql-rsocket/src/main/java/io/backend/skeleton/graphql/advanced/rsocket/GraphQlRSocketAuthentication.java' 'modules/graphql-advanced/graphql-rsocket/src/main/java/io/backend/skeleton/graphql/advanced/rsocket/GraphQlRSocketErrorMapper.java' 'modules/graphql-advanced/graphql-rsocket/src/main/java/io/backend/skeleton/graphql/advanced/rsocket/GraphQlRSocketCapability.java' 'modules/graphql-advanced/graphql-rsocket/src/test/java/io/backend/skeleton/graphql/advanced/rsocket/GraphQlRSocketRoutePolicyTest.java' +git commit -m "feat: add experimental graphql rsocket" +``` + +### Task 17: GraphQL over HTTP GET Draft Profile + +**Files:** +- Create: `modules/graphql-advanced/graphql-http-draft/src/main/java/io/backend/skeleton/graphql/advanced/get/GraphQlHttpGetProfile.java` +- Create: `modules/graphql-advanced/graphql-http-draft/src/main/java/io/backend/skeleton/graphql/advanced/get/GraphQlHttpGetRequestParser.java` +- Create: `modules/graphql-advanced/graphql-http-draft/src/main/java/io/backend/skeleton/graphql/advanced/get/GraphQlHttpGetCachePolicy.java` +- Create: `modules/graphql-advanced/graphql-http-draft/src/main/java/io/backend/skeleton/graphql/advanced/get/GraphQlHttpGetCsrfPolicy.java` +- Create: `modules/graphql-advanced/graphql-http-draft/src/main/java/io/backend/skeleton/graphql/advanced/get/GraphQlHttpGetOperationPolicy.java` +- Create: `modules/graphql-advanced/graphql-http-draft/src/main/java/io/backend/skeleton/graphql/advanced/get/GraphQlHttpDraftCompatibilityReport.java` +- Test: `modules/graphql-advanced/graphql-http-draft/src/test/java/io/backend/skeleton/graphql/advanced/get/GraphQlHttpGetOperationPolicyTest.java` + +**Interfaces:** +- Consumes: GraphQL over HTTP Stage 2 Draft, Stable HTTP V1 policy와 experimental flag. +- Produces: Query-only GET parsing, cache·CSRF 정책과 draft compatibility report. + +**Implementation requirements:** +- Mutation과 Subscription을 GET으로 허용하지 않는다. +- Query, operationName, variables, extensions의 URI 크기와 encoding을 제한한다. +- Cookie credential 사용 시 CSRF와 cache behavior를 명시적으로 검증한다. +- Raw query와 variables가 access log, referer 또는 metric에 노출될 위험을 문서·테스트한다. +- Draft 변화가 Stable POST contract를 변경하지 않는다. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlHttpGetOperationPolicyTest { + @org.junit.jupiter.api.Test + void mutationOverGetIsRejected() { + var policy = GraphQlHttpGetOperationPolicy.queryOnly(); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> policy.verify("mutation")) + .isInstanceOf( + GraphQlHttpGetRejectedException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql-advanced:graphql-http-draft:test --tests 'io.backend.skeleton.graphql.advanced.get.GraphQlHttpGetOperationPolicyTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public final class GraphQlHttpGetOperationPolicy { + public static GraphQlHttpGetOperationPolicy queryOnly() { + return new GraphQlHttpGetOperationPolicy(); + } + + public void verify(String operationType) { + if (!"query".equals(operationType)) { + throw new GraphQlHttpGetRejectedException( + "GET supports query operations only"); + } + } +} + +public record GraphQlHttpGetProfile( + int maximumUriBytes, + boolean sharedCacheAllowed, + boolean csrfRequired) { +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql-advanced:graphql-http-draft:test --tests 'io.backend.skeleton.graphql.advanced.get.GraphQlHttpGetOperationPolicyTest' +./gradlew graphqlAdvancedTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql-advanced/graphql-http-draft/src/main/java/io/backend/skeleton/graphql/advanced/get/GraphQlHttpGetProfile.java' 'modules/graphql-advanced/graphql-http-draft/src/main/java/io/backend/skeleton/graphql/advanced/get/GraphQlHttpGetRequestParser.java' 'modules/graphql-advanced/graphql-http-draft/src/main/java/io/backend/skeleton/graphql/advanced/get/GraphQlHttpGetCachePolicy.java' 'modules/graphql-advanced/graphql-http-draft/src/main/java/io/backend/skeleton/graphql/advanced/get/GraphQlHttpGetCsrfPolicy.java' 'modules/graphql-advanced/graphql-http-draft/src/main/java/io/backend/skeleton/graphql/advanced/get/GraphQlHttpGetOperationPolicy.java' 'modules/graphql-advanced/graphql-http-draft/src/main/java/io/backend/skeleton/graphql/advanced/get/GraphQlHttpDraftCompatibilityReport.java' 'modules/graphql-advanced/graphql-http-draft/src/test/java/io/backend/skeleton/graphql/advanced/get/GraphQlHttpGetOperationPolicyTest.java' +git commit -m "feat: add experimental graphql http get" +``` + +### Task 18: Incremental Delivery Experimental Profile + +**Files:** +- Create: `modules/graphql-advanced/graphql-incremental-delivery/src/main/java/io/backend/skeleton/graphql/advanced/incremental/GraphQlIncrementalDeliveryCapability.java` +- Create: `modules/graphql-advanced/graphql-incremental-delivery/src/main/java/io/backend/skeleton/graphql/advanced/incremental/GraphQlIncrementalDeliveryProfile.java` +- Create: `modules/graphql-advanced/graphql-incremental-delivery/src/main/java/io/backend/skeleton/graphql/advanced/incremental/GraphQlIncrementalPatch.java` +- Create: `modules/graphql-advanced/graphql-incremental-delivery/src/main/java/io/backend/skeleton/graphql/advanced/incremental/GraphQlIncrementalTransportPolicy.java` +- Create: `modules/graphql-advanced/graphql-incremental-delivery/src/main/java/io/backend/skeleton/graphql/advanced/incremental/GraphQlIncrementalCancellation.java` +- Create: `modules/graphql-advanced/graphql-incremental-delivery/src/main/java/io/backend/skeleton/graphql/advanced/incremental/GraphQlIncrementalCompatibilityGate.java` +- Test: `modules/graphql-advanced/graphql-incremental-delivery/src/test/java/io/backend/skeleton/graphql/advanced/incremental/GraphQlIncrementalCompatibilityGateTest.java` + +**Interfaces:** +- Consumes: 실험 기능을 지원하는 GraphQL Java/Spring 조합, multipart/mixed 또는 streaming transport capability. +- Produces: `@defer`·`@stream` 계열 기능을 Stable contract와 분리한 version-gated experimental profile. + +**Implementation requirements:** +- September 2025 Stable schema contract에 존재하지 않거나 구현 조합이 안정화되지 않은 기능을 자동 활성화하지 않는다. +- 초기 result와 후속 patch의 error·path·ordering·cancellation 계약을 별도로 검증한다. +- Stable HTTP response byte budget과 timeout을 우회하지 않는다. +- Client capability negotiation 없이 incremental response를 반환하지 않는다. +- Compatibility gate가 실패하면 일반 non-incremental execution으로 silent fallback하지 않고 설정 오류로 차단한다. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlIncrementalCompatibilityGateTest { + @org.junit.jupiter.api.Test + void unsupportedRuntimeCannotEnableIncrementalDelivery() { + var gate = new GraphQlIncrementalCompatibilityGate(); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> gate.verify( + new GraphQlIncrementalDeliveryCapability( + false, false))) + .isInstanceOf( + GraphQlIncrementalDeliveryRejectedException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql-advanced:graphql-incremental-delivery:test --tests 'io.backend.skeleton.graphql.advanced.incremental.GraphQlIncrementalCompatibilityGateTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GraphQlIncrementalDeliveryCapability( + boolean engineSupported, + boolean transportSupported) { +} + +public final class GraphQlIncrementalCompatibilityGate { + public void verify( + GraphQlIncrementalDeliveryCapability capability) { + if (!capability.engineSupported() + || !capability.transportSupported()) { + throw new + GraphQlIncrementalDeliveryRejectedException( + "incremental delivery runtime unsupported"); + } + } +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql-advanced:graphql-incremental-delivery:test --tests 'io.backend.skeleton.graphql.advanced.incremental.GraphQlIncrementalCompatibilityGateTest' +./gradlew graphqlAdvancedTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql-advanced/graphql-incremental-delivery/src/main/java/io/backend/skeleton/graphql/advanced/incremental/GraphQlIncrementalDeliveryCapability.java' 'modules/graphql-advanced/graphql-incremental-delivery/src/main/java/io/backend/skeleton/graphql/advanced/incremental/GraphQlIncrementalDeliveryProfile.java' 'modules/graphql-advanced/graphql-incremental-delivery/src/main/java/io/backend/skeleton/graphql/advanced/incremental/GraphQlIncrementalPatch.java' 'modules/graphql-advanced/graphql-incremental-delivery/src/main/java/io/backend/skeleton/graphql/advanced/incremental/GraphQlIncrementalTransportPolicy.java' 'modules/graphql-advanced/graphql-incremental-delivery/src/main/java/io/backend/skeleton/graphql/advanced/incremental/GraphQlIncrementalCancellation.java' 'modules/graphql-advanced/graphql-incremental-delivery/src/main/java/io/backend/skeleton/graphql/advanced/incremental/GraphQlIncrementalCompatibilityGate.java' 'modules/graphql-advanced/graphql-incremental-delivery/src/test/java/io/backend/skeleton/graphql/advanced/incremental/GraphQlIncrementalCompatibilityGateTest.java' +git commit -m "feat: add experimental graphql incremental delivery" +``` + +### Task 19: Advanced Capability Promotion·Soak·Release Gate + +**Files:** +- Create: `modules/graphql-advanced/graphql-advanced-bootstrap/src/main/java/io/backend/skeleton/graphql/advanced/release/GraphQlAdvancedReleaseEvidence.java` +- Create: `modules/graphql-advanced/graphql-advanced-bootstrap/src/main/java/io/backend/skeleton/graphql/advanced/release/GraphQlAdvancedReleaseGate.java` +- Create: `modules/graphql-advanced/graphql-advanced-bootstrap/src/main/java/io/backend/skeleton/graphql/advanced/release/GraphQlAdvancedPromotionDecision.java` +- Create: `modules/graphql-advanced/graphql-advanced-bootstrap/src/main/java/io/backend/skeleton/graphql/advanced/release/GraphQlAdvancedSoakScenario.java` +- Create: `modules/graphql-advanced/graphql-advanced-bootstrap/src/main/java/io/backend/skeleton/graphql/advanced/release/GraphQlAdvancedCompatibilityMatrix.java` +- Create: `modules/graphql-advanced/graphql-advanced-bootstrap/src/main/java/io/backend/skeleton/graphql/advanced/release/GraphQlAdvancedRunbookIndex.java` +- Create: `modules/graphql-advanced/graphql-advanced-bootstrap/src/main/java/io/backend/skeleton/graphql/advanced/release/GraphQlAdvancedReleaseFailure.java` +- Test: `modules/graphql-advanced/graphql-advanced-bootstrap/src/test/java/io/backend/skeleton/graphql/advanced/release/GraphQlAdvancedReleaseGateTest.java` + +**Interfaces:** +- Consumes: Stable Task 1–48 release evidence와 Advanced capability별 composition·soak·security·compatibility evidence. +- Produces: Advanced Stable 승격과 Experimental 유지·철회를 결정하는 capability별 release gate. + +**Implementation requirements:** +- Stable Task 1–48의 release gate가 통과하지 않으면 Advanced 계획을 시작하지 않는다. +- WebSocket·SSE는 장기 connection soak, slow consumer, auth expiry, cancellation, shutdown drain evidence를 요구한다. +- Federation은 composition, router integration, cross-subgraph latency와 partial failure evidence를 요구한다. +- Persisted Operation은 registry durability, block propagation, usage와 schema compatibility evidence를 요구한다. +- RSocket·HTTP GET·Incremental Delivery는 명시적 승격 ADR 전까지 Experimental로 남는다. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlAdvancedReleaseGateTest { + @org.junit.jupiter.api.Test + void advancedCannotReleaseBeforeStableBaseline() { + var evidence = new GraphQlAdvancedReleaseEvidence( + false, true, true, true, true); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> new GraphQlAdvancedReleaseGate().verify(evidence)) + .isInstanceOf(GraphQlAdvancedReleaseFailure.class) + .hasMessageContaining("stable"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql-advanced:graphql-advanced-bootstrap:test --tests 'io.backend.skeleton.graphql.advanced.release.GraphQlAdvancedReleaseGateTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GraphQlAdvancedReleaseEvidence( + boolean stableBaselinePassed, + boolean capabilityContractsPassed, + boolean securityPassed, + boolean soakPassed, + boolean compatibilityPassed) { +} + +public final class GraphQlAdvancedReleaseGate { + public void verify(GraphQlAdvancedReleaseEvidence evidence) { + if (!evidence.stableBaselinePassed()) { + throw new GraphQlAdvancedReleaseFailure( + "stable graphql baseline must pass first"); + } + if (!evidence.capabilityContractsPassed() + || !evidence.securityPassed() + || !evidence.soakPassed() + || !evidence.compatibilityPassed()) { + throw new GraphQlAdvancedReleaseFailure( + "advanced graphql evidence incomplete"); + } + } +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql-advanced:graphql-advanced-bootstrap:test --tests 'io.backend.skeleton.graphql.advanced.release.GraphQlAdvancedReleaseGateTest' +./gradlew graphqlAdvancedTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql-advanced/graphql-advanced-bootstrap/src/main/java/io/backend/skeleton/graphql/advanced/release/GraphQlAdvancedReleaseEvidence.java' 'modules/graphql-advanced/graphql-advanced-bootstrap/src/main/java/io/backend/skeleton/graphql/advanced/release/GraphQlAdvancedReleaseGate.java' 'modules/graphql-advanced/graphql-advanced-bootstrap/src/main/java/io/backend/skeleton/graphql/advanced/release/GraphQlAdvancedPromotionDecision.java' 'modules/graphql-advanced/graphql-advanced-bootstrap/src/main/java/io/backend/skeleton/graphql/advanced/release/GraphQlAdvancedSoakScenario.java' 'modules/graphql-advanced/graphql-advanced-bootstrap/src/main/java/io/backend/skeleton/graphql/advanced/release/GraphQlAdvancedCompatibilityMatrix.java' 'modules/graphql-advanced/graphql-advanced-bootstrap/src/main/java/io/backend/skeleton/graphql/advanced/release/GraphQlAdvancedRunbookIndex.java' 'modules/graphql-advanced/graphql-advanced-bootstrap/src/main/java/io/backend/skeleton/graphql/advanced/release/GraphQlAdvancedReleaseFailure.java' 'modules/graphql-advanced/graphql-advanced-bootstrap/src/test/java/io/backend/skeleton/graphql/advanced/release/GraphQlAdvancedReleaseGateTest.java' +git commit -m "chore: add graphql advanced release gate" +``` diff --git a/docs/graphql-superpowers-package/docs/superpowers/plans/2026-08-12-graphql-api-execution-platform-implementation-plan.md b/docs/graphql-superpowers-package/docs/superpowers/plans/2026-08-12-graphql-api-execution-platform-implementation-plan.md new file mode 100644 index 00000000..2f5204e1 --- /dev/null +++ b/docs/graphql-superpowers-package/docs/superpowers/plans/2026-08-12-graphql-api-execution-platform-implementation-plan.md @@ -0,0 +1,4560 @@ +# GraphQL API 실행 플랫폼 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** SDL 기반 GraphQL 외부 계약을 Spring MVC·WebFlux transport, 정책 기반 실행, 요청 단위 DataLoader, signed cursor, 일관된 error·security·observability 계약으로 Application Use Case에 연결하는 Stable GraphQL API 실행 플랫폼을 구축한다. + +**Architecture:** 도메인 모듈이 SDL fragment, resolver, transport DTO와 Application Use Case 연결을 소유하고 GraphQL 플랫폼은 schema assembly, execution policy, transport, security, cost, DataLoader, pagination, error, observability와 release gate를 소유한다. JPA Entity·Mongo Document·Messaging event·Fileserver binary를 직접 노출하지 않으며, Single Executable Schema와 HTTP POST를 Stable 기본값으로 구현한다. + +**Tech Stack:** Java 21, Gradle Kotlin DSL, Spring Boot 4.1 BOM, Spring for GraphQL 2.0 계열, Boot-managed GraphQL Java v25 계열, Spring MVC, Spring WebFlux, Project Reactor, Micrometer Observation, JUnit 5, AssertJ, ArchUnit, Testcontainers PostgreSQL·MongoDB. + +## Global Constraints + +- Java runtime은 `21`이다. +- Dependency version의 Source of Truth는 Spring Boot `4.1` BOM이다. +- Spring for GraphQL은 `2.0` 계열을 사용하며 GraphQL Java를 독립적으로 임의 override하지 않는다. +- GraphQL language·execution contract는 September 2025 Edition을 기준으로 한다. +- GraphQL over HTTP는 Stage 2 Draft이므로 플랫폼 `GraphQlHttpProfile.V1`으로 동작을 고정한다. +- Stable HTTP transport는 JSON body를 받는 `POST`만 지원한다. +- `application/graphql-response+json`을 preferred response media type으로 사용하고 `application/json`은 compatibility로 유지한다. +- Validation을 통과해 execution이 시작된 field error와 partial data는 HTTP `200`을 사용한다. +- Draft의 이동 중인 `294` status 제안은 Stable contract에 포함하지 않는다. +- SDL이 외부 API 계약의 Source of Truth이다. +- JPA Entity, MongoDB Document, provider SDK model과 자유형 `Map`를 GraphQL output으로 반환하지 않는다. +- GraphQL resolver는 Repository, `EntityManager`, `MongoTemplate`, broker ACK, HTTP retry와 binary streaming을 직접 소유하지 않는다. +- Mutation root field 하나는 Application Use Case 하나를 호출한다. +- 여러 mutation root field를 하나의 request-wide DB transaction으로 묶지 않는다. +- DataLoader instance와 cache는 GraphQL execution 단위이다. +- Cursor는 version, query profile, sort keyset, filter fingerprint와 HMAC을 포함한다. +- Binary upload는 GraphQL multipart가 아니라 Fileserver upload reservation을 사용한다. +- Query cost는 depth뿐 아니라 field·alias·fragment·cardinality·resolver weight를 함께 계산한다. +- Metric label에 raw query, variables, cursor, object ID, raw tenant/user ID와 token을 넣지 않는다. +- Stable 기본은 Single Executable Schema이다. +- WebSocket·SSE·Persisted Operation·Federation·RSocket·HTTP GET은 이 계획이 아니라 Advanced 계획에서 구현한다. +- Stable module root는 `modules/graphql`이다. +- Root package는 `io.backend.skeleton.graphql`이다. +- 모든 task는 red-green TDD와 독립 commit으로 끝난다. +- 실제 저장소 구조가 이 문서의 예상 경로와 다르면 경로만 매핑하고 공개 계약·불변 조건·테스트 의미는 변경하지 않는다. + +--- + +## Execution Baseline + +```text +Stable Task 1–48 +→ Stable Release Gate +→ Advanced Task 1–19 +``` + +## Stable Module Map + +```text +modules/graphql/ +├── graphql-core-api +├── graphql-schema +├── graphql-execution +├── graphql-controller +├── graphql-http +├── graphql-dataloader +├── graphql-pagination +├── graphql-security +├── graphql-cost-control +├── graphql-error +├── graphql-observability +├── graphql-spring-boot-starter +├── graphql-testkit-core +├── graphql-testkit-schema +├── graphql-testkit-http +└── graphql-testkit-security +``` + +## File Ownership Rules + +```text +graphql-core-api +→ bounded identifiers, request context, client/operation policy contracts + +graphql-schema +→ SDL discovery, assembly, scalar, oneOf, mapping inspection, + compatibility and usage gates + +graphql-http +→ HTTP V1 transport envelope, media type and status behavior + +graphql-execution +→ interceptor order, operation policy, timeout, fetch profile, + mutation execution and preparsed cache + +graphql-controller +→ annotated resolver conventions, DTO and mutation result mapping + +graphql-dataloader +→ request-scoped loader, batch policy, chunking and key-level result + +graphql-pagination +→ signed cursor, connection, edge and page info + +graphql-security +→ authentication, actor/tenant context and authorization boundary + +graphql-cost-control +→ parser, structural, complexity and runtime response budgets + +graphql-error +→ stable wire error and exception resolution + +graphql-observability +→ Spring GraphQL/Micrometer convention and cardinality controls + +graphql-spring-boot-starter +→ auto-configuration, startup validation and actuator report + +graphql-testkit-* +→ schema, transport, security, persistence, fault and release evidence +``` + +## Delivery Phases + +| Phase | Tasks | Independently testable result | +|---|---:|---| +| Foundation | 1–7 | 모듈·identifier·context·policy·schema/scalar core | +| Schema Contract | 8–14 | deterministic SDL, mapping, evolution, scalar, oneOf | +| HTTP·Execution | 15–23 | Stable HTTP V1, MVC/WebFlux, timeout, resolver boundary | +| Error·Security | 24–29 | partial data/error, auth, field/object/tenant isolation | +| Cost·Cache | 30–35 | parser/shape/complexity/runtime budgets, operation naming, cache | +| Data Access Planning | 36–40 | request-scoped DataLoader와 finite Fetch Profile | +| Pagination·Mutation | 41–44 | signed connection cursor와 mutation contracts | +| Operations·Release | 45–48 | observability, starter, cross-module contracts, release gate | + +--- + +### Task 1: Gradle 멀티모듈과 GraphQL 품질 Test Suite 구성 + +**Files:** +- Create: `build-logic/src/main/kotlin/graphql-library-conventions.gradle.kts` +- Create: `modules/graphql/graphql-core-api/build.gradle.kts` +- Create: `modules/graphql/graphql-schema/build.gradle.kts` +- Create: `modules/graphql/graphql-execution/build.gradle.kts` +- Create: `modules/graphql/graphql-controller/build.gradle.kts` +- Create: `modules/graphql/graphql-http/build.gradle.kts` +- Create: `modules/graphql/graphql-dataloader/build.gradle.kts` +- Create: `modules/graphql/graphql-pagination/build.gradle.kts` +- Create: `modules/graphql/graphql-security/build.gradle.kts` +- Create: `modules/graphql/graphql-cost-control/build.gradle.kts` +- Create: `modules/graphql/graphql-error/build.gradle.kts` +- Create: `modules/graphql/graphql-observability/build.gradle.kts` +- Create: `modules/graphql/graphql-spring-boot-starter/build.gradle.kts` +- Create: `modules/graphql/graphql-testkit-core/build.gradle.kts` +- Create: `modules/graphql/graphql-testkit-schema/build.gradle.kts` +- Create: `modules/graphql/graphql-testkit-http/build.gradle.kts` +- Create: `modules/graphql/graphql-testkit-integration/build.gradle.kts` +- Test: `build-logic/src/test/java/GraphQlModuleBoundaryTest.java` + +**Interfaces:** +- Consumes: Host repository version catalog and Spring Boot 4.1 dependency management. +- Produces: 16 isolated Stable modules and `graphqlStableTest`, `graphqlContractTest`, `graphqlPerformanceTest` aggregate tasks. + +**Implementation requirements:** +- Apply Java 21 toolchains and use the Spring Boot BOM for Spring for GraphQL and GraphQL Java. +- Keep `graphql-core-api` free of Spring, GraphQL Java, Reactor and persistence dependencies. +- Do not include any Advanced module in the Stable dependency graph. +- Keep external load and soak tests outside the default unit test task. +- Fail the build when a Stable module depends on `modules/graphql-advanced`. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlModuleBoundaryTest { + @org.junit.jupiter.api.Test + void stableGraphDoesNotContainAdvancedModules() { + org.assertj.core.api.Assertions.assertThat(GraphQlBuildModel.stableModules()) + .contains("graphql-core-api", "graphql-http") + .doesNotContain("graphql-websocket", "graphql-federation"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew build-logic:test --tests 'GraphQlModuleBoundaryTest' +``` + +Expected: FAIL because the GraphQL module graph and build model do not exist. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public final class GraphQlBuildModel { + private static final java.util.Set STABLE = java.util.Set.of( + "graphql-core-api", "graphql-schema", "graphql-execution", + "graphql-controller", "graphql-http", "graphql-dataloader", + "graphql-pagination", "graphql-security", "graphql-cost-control", + "graphql-error", "graphql-observability", + "graphql-spring-boot-starter", "graphql-testkit-core", + "graphql-testkit-schema", "graphql-testkit-http", + "graphql-testkit-integration"); + + public static java.util.Set stableModules() { + return STABLE; + } + + private GraphQlBuildModel() {} +} +``` + +Create all module build files, aggregate suites and boundary checks listed above. Do not add Advanced dependencies to the Stable starter. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew build-logic:test --tests 'GraphQlModuleBoundaryTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the boundary test and the Stable aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'build-logic/src/main/kotlin/graphql-library-conventions.gradle.kts' 'modules/graphql/graphql-core-api/build.gradle.kts' 'modules/graphql/graphql-schema/build.gradle.kts' 'modules/graphql/graphql-execution/build.gradle.kts' 'modules/graphql/graphql-controller/build.gradle.kts' 'modules/graphql/graphql-http/build.gradle.kts' 'modules/graphql/graphql-dataloader/build.gradle.kts' 'modules/graphql/graphql-pagination/build.gradle.kts' 'modules/graphql/graphql-security/build.gradle.kts' 'modules/graphql/graphql-cost-control/build.gradle.kts' 'modules/graphql/graphql-error/build.gradle.kts' 'modules/graphql/graphql-observability/build.gradle.kts' 'modules/graphql/graphql-spring-boot-starter/build.gradle.kts' 'modules/graphql/graphql-testkit-core/build.gradle.kts' 'modules/graphql/graphql-testkit-schema/build.gradle.kts' 'modules/graphql/graphql-testkit-http/build.gradle.kts' 'modules/graphql/graphql-testkit-integration/build.gradle.kts' 'build-logic/src/test/java/GraphQlModuleBoundaryTest.java' +git commit -m "build: add graphql stable modules and test suites" +``` + +### Task 2: Core Operation·Client Profile 식별자 + +**Files:** +- Create: `modules/graphql/graphql-core-api/src/main/java/io/backend/skeleton/graphql/api/GraphQlOperationName.java` +- Create: `modules/graphql/graphql-core-api/src/main/java/io/backend/skeleton/graphql/api/GraphQlOperationId.java` +- Create: `modules/graphql/graphql-core-api/src/main/java/io/backend/skeleton/graphql/api/GraphQlClientProfile.java` +- Create: `modules/graphql/graphql-core-api/src/main/java/io/backend/skeleton/graphql/api/GraphQlSchemaCoordinate.java` +- Test: `modules/graphql/graphql-core-api/src/test/java/io/backend/skeleton/graphql/api/GraphQlIdentifiersTest.java` + +**Interfaces:** +- Consumes: Java 21 standard library only. +- Produces: Bounded low-cardinality identifiers shared by every platform module. + +**Implementation requirements:** +- Operation names match `[A-Za-z][_0-9A-Za-z]{2,127}`; anonymous operations use an explicit type rather than an empty string. +- Client profiles and schema coordinates reject path separators, whitespace and UUID-like dynamic values. +- Identifiers never contain actor, tenant, object or provider request IDs. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlIdentifiersTest { + @org.junit.jupiter.api.Test + void rejectsDynamicClientProfile() { + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> new GraphQlClientProfile("tenant/" + java.util.UUID.randomUUID())) + .isInstanceOf(IllegalArgumentException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-core-api:test --tests 'io.backend.skeleton.graphql.api.GraphQlIdentifiersTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GraphQlClientProfile(String value) { + public GraphQlClientProfile { + if (value == null || !value.matches("[a-z][a-z0-9.-]{2,63}")) { + throw new IllegalArgumentException("invalid GraphQL client profile"); + } + } +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-core-api:test --tests 'io.backend.skeleton.graphql.api.GraphQlIdentifiersTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-core-api/src/main/java/io/backend/skeleton/graphql/api/GraphQlOperationName.java' 'modules/graphql/graphql-core-api/src/main/java/io/backend/skeleton/graphql/api/GraphQlOperationId.java' 'modules/graphql/graphql-core-api/src/main/java/io/backend/skeleton/graphql/api/GraphQlClientProfile.java' 'modules/graphql/graphql-core-api/src/main/java/io/backend/skeleton/graphql/api/GraphQlSchemaCoordinate.java' 'modules/graphql/graphql-core-api/src/test/java/io/backend/skeleton/graphql/api/GraphQlIdentifiersTest.java' +git commit -m "feat: add graphql bounded identifiers" +``` + +### Task 3: Immutable GraphQlRequestContext와 Deadline + +**Files:** +- Create: `modules/graphql/graphql-core-api/src/main/java/io/backend/skeleton/graphql/context/GraphQlRequestContext.java` +- Create: `modules/graphql/graphql-core-api/src/main/java/io/backend/skeleton/graphql/context/GraphQlDeadline.java` +- Create: `modules/graphql/graphql-core-api/src/main/java/io/backend/skeleton/graphql/context/ActorRef.java` +- Create: `modules/graphql/graphql-core-api/src/main/java/io/backend/skeleton/graphql/context/TenantContext.java` +- Test: `modules/graphql/graphql-core-api/src/test/java/io/backend/skeleton/graphql/context/GraphQlRequestContextTest.java` + +**Interfaces:** +- Consumes: Core identifiers from Task 2. +- Produces: Immutable actor, tenant, client, locale, operation and deadline context. + +**Implementation requirements:** +- Tenant context is created from trusted authentication data, never a GraphQL argument. +- Deadline exposes remaining time from an injected Clock and has no mutable state. +- Context contains no access token, cookie or raw provider claim. +- The same semantic context can be bridged to executor and Reactor Context. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlRequestContextTest { + @org.junit.jupiter.api.Test + void rejectsExpiredDeadlineAgainstClock() { + java.time.Clock clock = java.time.Clock.fixed( + java.time.Instant.parse("2026-08-12T00:00:00Z"), + java.time.ZoneOffset.UTC); + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> GraphQlDeadline.of(java.time.Instant.parse("2026-08-11T23:59:59Z"), clock)) + .isInstanceOf(IllegalArgumentException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-core-api:test --tests 'io.backend.skeleton.graphql.context.GraphQlRequestContextTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GraphQlDeadline(java.time.Instant value) { + public static GraphQlDeadline of(java.time.Instant value, java.time.Clock clock) { + if (value == null || !value.isAfter(clock.instant())) { + throw new IllegalArgumentException("deadline must be in the future"); + } + return new GraphQlDeadline(value); + } + + public java.time.Duration remaining(java.time.Clock clock) { + return java.time.Duration.between(clock.instant(), value); + } +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-core-api:test --tests 'io.backend.skeleton.graphql.context.GraphQlRequestContextTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-core-api/src/main/java/io/backend/skeleton/graphql/context/GraphQlRequestContext.java' 'modules/graphql/graphql-core-api/src/main/java/io/backend/skeleton/graphql/context/GraphQlDeadline.java' 'modules/graphql/graphql-core-api/src/main/java/io/backend/skeleton/graphql/context/ActorRef.java' 'modules/graphql/graphql-core-api/src/main/java/io/backend/skeleton/graphql/context/TenantContext.java' 'modules/graphql/graphql-core-api/src/test/java/io/backend/skeleton/graphql/context/GraphQlRequestContextTest.java' +git commit -m "feat: add graphql request context and deadline" +``` + +### Task 4: Client Policy와 환경별 Manifest + +**Files:** +- Create: `modules/graphql/graphql-core-api/src/main/java/io/backend/skeleton/graphql/policy/GraphQlClientPolicy.java` +- Create: `modules/graphql/graphql-core-api/src/main/java/io/backend/skeleton/graphql/policy/GraphQlClientPolicyManifest.java` +- Create: `modules/graphql/graphql-core-api/src/main/java/io/backend/skeleton/graphql/policy/GraphQlPolicyViolation.java` +- Test: `modules/graphql/graphql-core-api/src/test/java/io/backend/skeleton/graphql/policy/GraphQlClientPolicyTest.java` + +**Interfaces:** +- Consumes: Client profile identifiers and Java time types. +- Produces: Validated request, page, cost, response and introspection limits per client profile. + +**Implementation requirements:** +- Every numeric limit is positive. +- Maximum page size is not below default page size. +- Production profiles can require named operations and persisted-only mode. +- Policy objects contain no raw query, actor, tenant or credential value. +- Manifests reject duplicate client profiles. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlClientPolicyTest { + @org.junit.jupiter.api.Test + void rejectsDefaultPageAboveMaximum() { + org.assertj.core.api.Assertions.assertThatThrownBy(() -> + new GraphQlClientPolicy( + 65536, 65536, 12, 500, 50, 50, 1000, + 100, 20, 10000, 10000, 5_242_880, + java.time.Duration.ofSeconds(5), false, false, true)) + .isInstanceOf(IllegalArgumentException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-core-api:test --tests 'io.backend.skeleton.graphql.policy.GraphQlClientPolicyTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GraphQlClientPolicy( + int maxDocumentBytes, + int maxVariablesBytes, + int maxDepth, + int maxFields, + int maxAliases, + int maxFragments, + int maxInputListElements, + int defaultPageSize, + int maxPageSize, + long maxComplexity, + long maxResponseNodes, + long maxResponseBytes, + java.time.Duration maxExecutionTime, + boolean introspectionAllowed, + boolean persistedOperationOnly, + boolean namedOperationRequired) { + + public GraphQlClientPolicy { + if (defaultPageSize < 1 || maxPageSize < defaultPageSize) { + throw new IllegalArgumentException("invalid page policy"); + } + } +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-core-api:test --tests 'io.backend.skeleton.graphql.policy.GraphQlClientPolicyTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-core-api/src/main/java/io/backend/skeleton/graphql/policy/GraphQlClientPolicy.java' 'modules/graphql/graphql-core-api/src/main/java/io/backend/skeleton/graphql/policy/GraphQlClientPolicyManifest.java' 'modules/graphql/graphql-core-api/src/main/java/io/backend/skeleton/graphql/policy/GraphQlPolicyViolation.java' 'modules/graphql/graphql-core-api/src/test/java/io/backend/skeleton/graphql/policy/GraphQlClientPolicyTest.java' +git commit -m "feat: add graphql client policy manifest" +``` + +### Task 5: Operation Policy와 실행 유형 Catalog + +**Files:** +- Create: `modules/graphql/graphql-core-api/src/main/java/io/backend/skeleton/graphql/policy/GraphQlOperationPolicy.java` +- Create: `modules/graphql/graphql-core-api/src/main/java/io/backend/skeleton/graphql/policy/GraphQlOperationType.java` +- Create: `modules/graphql/graphql-core-api/src/main/java/io/backend/skeleton/graphql/policy/ResolverExecutionType.java` +- Create: `modules/graphql/graphql-core-api/src/main/java/io/backend/skeleton/graphql/policy/GraphQlOperationCatalog.java` +- Test: `modules/graphql/graphql-core-api/src/test/java/io/backend/skeleton/graphql/policy/GraphQlOperationPolicyTest.java` + +**Interfaces:** +- Consumes: Core identifiers and client policy. +- Produces: Registered operation metadata for query, mutation and subscription execution. + +**Implementation requirements:** +- Every production operation has a registered name and schema coordinate. +- Mutation policy may require idempotency and expected version. +- `STREAM` execution type is valid only for subscription operations. +- Dynamic resolver names are rejected. +- Catalog registration fails on duplicate operation names. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlOperationPolicyTest { + @org.junit.jupiter.api.Test + void rejectsStreamQuery() { + org.assertj.core.api.Assertions.assertThatThrownBy(() -> + new GraphQlOperationPolicy( + new GraphQlOperationName("GetOrder"), + GraphQlOperationType.QUERY, + ResolverExecutionType.STREAM, + false)) + .isInstanceOf(IllegalArgumentException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-core-api:test --tests 'io.backend.skeleton.graphql.policy.GraphQlOperationPolicyTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GraphQlOperationPolicy( + GraphQlOperationName name, + GraphQlOperationType operationType, + ResolverExecutionType executionType, + boolean idempotencyRequired) { + + public GraphQlOperationPolicy { + if (executionType == ResolverExecutionType.STREAM + && operationType != GraphQlOperationType.SUBSCRIPTION) { + throw new IllegalArgumentException("stream resolver requires subscription"); + } + } +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-core-api:test --tests 'io.backend.skeleton.graphql.policy.GraphQlOperationPolicyTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-core-api/src/main/java/io/backend/skeleton/graphql/policy/GraphQlOperationPolicy.java' 'modules/graphql/graphql-core-api/src/main/java/io/backend/skeleton/graphql/policy/GraphQlOperationType.java' 'modules/graphql/graphql-core-api/src/main/java/io/backend/skeleton/graphql/policy/ResolverExecutionType.java' 'modules/graphql/graphql-core-api/src/main/java/io/backend/skeleton/graphql/policy/GraphQlOperationCatalog.java' 'modules/graphql/graphql-core-api/src/test/java/io/backend/skeleton/graphql/policy/GraphQlOperationPolicyTest.java' +git commit -m "feat: add graphql operation policy catalog" +``` + +### Task 6: Schema Contract와 Fingerprint + +**Files:** +- Create: `modules/graphql/graphql-core-api/src/main/java/io/backend/skeleton/graphql/schema/GraphQlSchemaContract.java` +- Create: `modules/graphql/graphql-core-api/src/main/java/io/backend/skeleton/graphql/schema/GraphQlSchemaHash.java` +- Create: `modules/graphql/graphql-core-api/src/main/java/io/backend/skeleton/graphql/schema/GraphQlContractVersion.java` +- Test: `modules/graphql/graphql-core-api/src/test/java/io/backend/skeleton/graphql/schema/GraphQlSchemaContractTest.java` + +**Interfaces:** +- Consumes: Core identifiers and standard cryptography. +- Produces: Schema hash plus breaking, scalar and directive policy versions. + +**Implementation requirements:** +- Canonical SDL bytes are hashed with SHA-256. +- Schema hash is never the only compatibility decision. +- Breaking, scalar and directive policy versions are mandatory. +- Hash formatting is lowercase hexadecimal. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlSchemaContractTest { + @org.junit.jupiter.api.Test + void sameCanonicalSdlProducesSameHash() { + org.assertj.core.api.Assertions.assertThat( + GraphQlSchemaHash.sha256("type Query { ping: String! }").value()) + .isEqualTo(GraphQlSchemaHash.sha256("type Query { ping: String! }").value()); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-core-api:test --tests 'io.backend.skeleton.graphql.schema.GraphQlSchemaContractTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GraphQlSchemaContract( + GraphQlSchemaHash schemaHash, + GraphQlContractVersion breakingPolicyVersion, + GraphQlContractVersion scalarManifestVersion, + GraphQlContractVersion directiveManifestVersion) { +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-core-api:test --tests 'io.backend.skeleton.graphql.schema.GraphQlSchemaContractTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-core-api/src/main/java/io/backend/skeleton/graphql/schema/GraphQlSchemaContract.java' 'modules/graphql/graphql-core-api/src/main/java/io/backend/skeleton/graphql/schema/GraphQlSchemaHash.java' 'modules/graphql/graphql-core-api/src/main/java/io/backend/skeleton/graphql/schema/GraphQlContractVersion.java' 'modules/graphql/graphql-core-api/src/test/java/io/backend/skeleton/graphql/schema/GraphQlSchemaContractTest.java' +git commit -m "feat: add graphql schema contract fingerprint" +``` + +### Task 7: Scalar Manifest와 Coercion 계약 + +**Files:** +- Create: `modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/schema/GraphQlScalarManifest.java` +- Create: `modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/schema/GraphQlScalarDefinition.java` +- Create: `modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/schema/GraphQlScalarPolicy.java` +- Test: `modules/graphql/graphql-schema/src/test/java/io/backend/skeleton/graphql/schema/GraphQlScalarManifestTest.java` + +**Interfaces:** +- Consumes: Schema contract and GraphQL Java scalar SPI. +- Produces: Approved ID, UUID, Instant, Date, BigDecimal and Long scalar definitions. + +**Implementation requirements:** +- `Upload` scalar is always rejected. +- `JSON` scalar requires a coordinate allowlist and cannot be a global default input. +- BigDecimal and Long coercion reject silent precision loss. +- Every custom scalar has a stable name and optional specified-by URI. +- Duplicate scalar names fail manifest construction. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlScalarManifestTest { + @org.junit.jupiter.api.Test + void uploadScalarIsForbidden() { + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> GraphQlScalarManifest.of(GraphQlScalarDefinition.named("Upload"))) + .isInstanceOf(IllegalArgumentException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-schema:test --tests 'io.backend.skeleton.graphql.schema.GraphQlScalarManifestTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GraphQlScalarDefinition(String name, java.net.URI specifiedBy) { + public GraphQlScalarDefinition { + if ("Upload".equals(name)) { + throw new IllegalArgumentException("Upload scalar is unsupported"); + } + } + + public static GraphQlScalarDefinition named(String name) { + return new GraphQlScalarDefinition(name, null); + } +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-schema:test --tests 'io.backend.skeleton.graphql.schema.GraphQlScalarManifestTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/schema/GraphQlScalarManifest.java' 'modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/schema/GraphQlScalarDefinition.java' 'modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/schema/GraphQlScalarPolicy.java' 'modules/graphql/graphql-schema/src/test/java/io/backend/skeleton/graphql/schema/GraphQlScalarManifestTest.java' +git commit -m "feat: add graphql scalar manifest" +``` + +### Task 8: SDL Resource Discovery와 Deterministic Assembly + +**Files:** +- Create: `modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/schema/GraphQlSchemaResource.java` +- Create: `modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/schema/GraphQlSchemaAssembler.java` +- Create: `modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/schema/GraphQlSchemaAssemblyResult.java` +- Create: `modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/schema/GraphQlSchemaOwnership.java` +- Test: `modules/graphql/graphql-schema/src/test/java/io/backend/skeleton/graphql/schema/GraphQlSchemaAssemblerTest.java` + +**Interfaces:** +- Consumes: Scalar manifest, GraphQL Java SDL parser and Spring resource abstraction. +- Produces: Deterministic classpath fragment assembly with ownership and duplicate detection. + +**Implementation requirements:** +- Load only `.graphqls` and `.gqls` under approved roots. +- Sort resources by logical module and path before assembly. +- Reject duplicate type, field, directive and scalar declarations. +- Preserve a resource-to-coordinate ownership map for diagnostics. +- Do not rely on filesystem enumeration order. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlSchemaAssemblerTest { + @org.junit.jupiter.api.Test + void duplicateRootTypeFailsAssembly() { + var resources = java.util.List.of( + GraphQlSchemaResource.memory("a", "type Query { a: String }"), + GraphQlSchemaResource.memory("b", "type Query { b: String }")); + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> GraphQlSchemaAssembler.defaults().assemble(resources)) + .isInstanceOf(GraphQlSchemaAssemblyException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-schema:test --tests 'io.backend.skeleton.graphql.schema.GraphQlSchemaAssemblerTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public final class GraphQlSchemaAssembler { + public GraphQlSchemaAssemblyResult assemble( + java.util.List resources) { + java.util.List ordered = resources.stream() + .sorted(java.util.Comparator.comparing(GraphQlSchemaResource::logicalPath)) + .toList(); + return GraphQlSchemaAssemblyResult.parseAndValidate(ordered); + } + + public static GraphQlSchemaAssembler defaults() { + return new GraphQlSchemaAssembler(); + } +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-schema:test --tests 'io.backend.skeleton.graphql.schema.GraphQlSchemaAssemblerTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/schema/GraphQlSchemaResource.java' 'modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/schema/GraphQlSchemaAssembler.java' 'modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/schema/GraphQlSchemaAssemblyResult.java' 'modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/schema/GraphQlSchemaOwnership.java' 'modules/graphql/graphql-schema/src/test/java/io/backend/skeleton/graphql/schema/GraphQlSchemaAssemblerTest.java' +git commit -m "feat: add deterministic graphql schema assembly" +``` + +### Task 9: SchemaMappingInspector Fail-fast Gate + +**Files:** +- Create: `modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/schema/GraphQlMappingInspectionGate.java` +- Create: `modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/schema/GraphQlMappingIssue.java` +- Create: `modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/schema/GraphQlMappingPolicy.java` +- Test: `modules/graphql/graphql-schema/src/test/java/io/backend/skeleton/graphql/schema/GraphQlMappingInspectionGateTest.java` + +**Interfaces:** +- Consumes: Assembled schema and Spring SchemaMappingInspector output. +- Produces: Startup gate for unmapped fields, unknown resolvers, argument and nullability mismatches. + +**Implementation requirements:** +- Stable profile fails on blocking mapping issues. +- Local profile may report warnings but cannot ignore forbidden scalar or unknown resolver. +- Issue output identifies schema coordinate and owning resource without PII. +- The gate runs after all controller, scalar and type-resolver wiring is registered. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlMappingInspectionGateTest { + @org.junit.jupiter.api.Test + void stableProfileRejectsUnmappedField() { + org.assertj.core.api.Assertions.assertThatThrownBy(() -> + GraphQlMappingInspectionGate.stable().verify( + java.util.List.of(GraphQlMappingIssue.unmapped("Order.total")))) + .isInstanceOf(GraphQlSchemaMappingException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-schema:test --tests 'io.backend.skeleton.graphql.schema.GraphQlMappingInspectionGateTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public final class GraphQlMappingInspectionGate { + public void verify(java.util.List issues) { + if (issues.stream().anyMatch(GraphQlMappingIssue::blocking)) { + throw new GraphQlSchemaMappingException(issues); + } + } + + public static GraphQlMappingInspectionGate stable() { + return new GraphQlMappingInspectionGate(); + } +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-schema:test --tests 'io.backend.skeleton.graphql.schema.GraphQlMappingInspectionGateTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/schema/GraphQlMappingInspectionGate.java' 'modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/schema/GraphQlMappingIssue.java' 'modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/schema/GraphQlMappingPolicy.java' 'modules/graphql/graphql-schema/src/test/java/io/backend/skeleton/graphql/schema/GraphQlMappingInspectionGateTest.java' +git commit -m "feat: add graphql mapping inspection gate" +``` + +### Task 10: Schema Compatibility Diff와 Breaking Policy + +**Files:** +- Create: `modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/compat/GraphQlSchemaChange.java` +- Create: `modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/compat/GraphQlCompatibilityPolicy.java` +- Create: `modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/compat/GraphQlCompatibilityReport.java` +- Create: `modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/compat/GraphQlSchemaComparator.java` +- Test: `modules/graphql/graphql-schema/src/test/java/io/backend/skeleton/graphql/compat/GraphQlSchemaComparatorTest.java` + +**Interfaces:** +- Consumes: Previous and candidate schema contracts. +- Produces: Wire and generated-client impact classification for schema changes. + +**Implementation requirements:** +- Field removal, required argument addition, input strengthening and output nullable transition are breaking. +- Enum and union additions are additive with generated-client review. +- Scalar coercion change requires a new scalar or version. +- Every change reports coordinate, wire impact, client impact and reason. +- Comparison order is deterministic. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlSchemaComparatorTest { + @org.junit.jupiter.api.Test + void requiredArgumentAdditionIsBreaking() { + GraphQlCompatibilityReport report = GraphQlSchemaComparator.compare( + "type Query { order: String }", + "type Query { order(id: ID!): String }"); + org.assertj.core.api.Assertions.assertThat(report.breaking()).isTrue(); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-schema:test --tests 'io.backend.skeleton.graphql.compat.GraphQlSchemaComparatorTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GraphQlSchemaChange( + String coordinate, + GraphQlChangeKind kind, + GraphQlCompatibilityImpact wireImpact, + GraphQlCompatibilityImpact generatedClientImpact, + String reason) { +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-schema:test --tests 'io.backend.skeleton.graphql.compat.GraphQlSchemaComparatorTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/compat/GraphQlSchemaChange.java' 'modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/compat/GraphQlCompatibilityPolicy.java' 'modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/compat/GraphQlCompatibilityReport.java' 'modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/compat/GraphQlSchemaComparator.java' 'modules/graphql/graphql-schema/src/test/java/io/backend/skeleton/graphql/compat/GraphQlSchemaComparatorTest.java' +git commit -m "feat: add graphql schema compatibility policy" +``` + +### Task 11: Schema Usage와 Deprecation Removal Gate + +**Files:** +- Create: `modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/compat/GraphQlSchemaUsage.java` +- Create: `modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/compat/GraphQlDeprecationGate.java` +- Create: `modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/compat/GraphQlRemovalDecision.java` +- Create: `modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/compat/GraphQlClientOwnerApproval.java` +- Test: `modules/graphql/graphql-schema/src/test/java/io/backend/skeleton/graphql/compat/GraphQlDeprecationGateTest.java` + +**Interfaces:** +- Consumes: Compatibility report and bounded operation usage catalog. +- Produces: Removal gate requiring deprecation, zero usage, persisted-reference scan and owner approval. + +**Implementation requirements:** +- Unknown usage is not treated as zero usage. +- Support window must have elapsed. +- Persisted operation references must be absent. +- Required input elements cannot be removed through a deprecation shortcut. +- Approval records contain owner references and reason, not secrets. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlDeprecationGateTest { + @org.junit.jupiter.api.Test + void unknownUsageBlocksRemoval() { + org.assertj.core.api.Assertions.assertThat( + GraphQlDeprecationGate.evaluate(GraphQlSchemaUsage.unknown()).allowed()) + .isFalse(); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-schema:test --tests 'io.backend.skeleton.graphql.compat.GraphQlDeprecationGateTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GraphQlRemovalDecision( + boolean allowed, + java.util.List reasons) { + + public GraphQlRemovalDecision { + reasons = java.util.List.copyOf(reasons); + } + + public static GraphQlRemovalDecision blocked(String reason) { + return new GraphQlRemovalDecision(false, java.util.List.of(reason)); + } +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-schema:test --tests 'io.backend.skeleton.graphql.compat.GraphQlDeprecationGateTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/compat/GraphQlSchemaUsage.java' 'modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/compat/GraphQlDeprecationGate.java' 'modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/compat/GraphQlRemovalDecision.java' 'modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/compat/GraphQlClientOwnerApproval.java' 'modules/graphql/graphql-schema/src/test/java/io/backend/skeleton/graphql/compat/GraphQlDeprecationGateTest.java' +git commit -m "feat: add graphql deprecation removal gate" +``` + +### Task 12: Resolver·DTO·Repository Boundary Architecture Rules + +**Files:** +- Create: `modules/graphql/graphql-controller/src/main/java/io/backend/skeleton/graphql/architecture/GraphQlResolverBoundaryRules.java` +- Create: `modules/graphql/graphql-controller/src/main/java/io/backend/skeleton/graphql/architecture/GraphQlTransportTypeRules.java` +- Create: `modules/graphql/graphql-controller/src/main/java/io/backend/skeleton/graphql/architecture/GraphQlControllerTransactionRule.java` +- Test: `modules/graphql/graphql-controller/src/test/java/io/backend/skeleton/graphql/architecture/GraphQlResolverBoundaryRulesTest.java` + +**Interfaces:** +- Consumes: Annotated controller package conventions and ArchUnit. +- Produces: Architecture rules that block persistence and provider exposure. + +**Implementation requirements:** +- Resolvers may depend on Application Use Case interfaces and DTO mappers. +- Resolvers cannot return JPA entities, Mongo documents, provider SDK types or unrestricted maps. +- GraphQL controller classes cannot carry transaction annotations. +- Raw DataFetcher implementation is restricted to infrastructure packages. +- Resolvers cannot depend directly on EntityManager, MongoTemplate or repository implementations. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlResolverBoundaryRulesTest { + @org.junit.jupiter.api.Test + void controllerMustNotDependOnEntityManager() { + GraphQlResolverBoundaryRules.assertNoPersistenceAccess( + "io.backend.skeleton.example.graphql"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-controller:test --tests 'io.backend.skeleton.graphql.architecture.GraphQlResolverBoundaryRulesTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public final class GraphQlResolverBoundaryRules { + public static void assertNoPersistenceAccess(String packageName) { + // Build the ArchUnit rule against EntityManager, MongoTemplate, + // repository implementations and provider SDK packages. + } + + private GraphQlResolverBoundaryRules() {} +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-controller:test --tests 'io.backend.skeleton.graphql.architecture.GraphQlResolverBoundaryRulesTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-controller/src/main/java/io/backend/skeleton/graphql/architecture/GraphQlResolverBoundaryRules.java' 'modules/graphql/graphql-controller/src/main/java/io/backend/skeleton/graphql/architecture/GraphQlTransportTypeRules.java' 'modules/graphql/graphql-controller/src/main/java/io/backend/skeleton/graphql/architecture/GraphQlControllerTransactionRule.java' 'modules/graphql/graphql-controller/src/test/java/io/backend/skeleton/graphql/architecture/GraphQlResolverBoundaryRulesTest.java' +git commit -m "test: enforce graphql resolver architecture boundaries" +``` + +### Task 13: Standard Custom Scalar Wiring + +**Files:** +- Create: `modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/scalar/UuidScalar.java` +- Create: `modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/scalar/InstantScalar.java` +- Create: `modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/scalar/DateScalar.java` +- Create: `modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/scalar/BigDecimalScalar.java` +- Create: `modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/scalar/LongScalar.java` +- Create: `modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/scalar/GraphQlScalarWiringConfigurer.java` +- Test: `modules/graphql/graphql-schema/src/test/java/io/backend/skeleton/graphql/scalar/GraphQlScalarContractTest.java` + +**Interfaces:** +- Consumes: Scalar manifest and GraphQL Java Coercing API. +- Produces: Strict serialization, parsing and variable coercion for Stable custom scalars. + +**Implementation requirements:** +- UUID accepts canonical string only. +- Instant emits UTC ISO-8601. +- BigDecimal rejects NaN, infinity and precision-loss conversion. +- Long follows the configured client numeric range policy. +- Coercion errors do not echo sensitive input values. +- `@oneOf` coercion is covered by a separate schema contract test. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlScalarContractTest { + @org.junit.jupiter.api.Test + void uuidRejectsInvalidValue() { + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> UuidScalar.parse("not-a-uuid")) + .isInstanceOf(graphql.schema.CoercingParseValueException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-schema:test --tests 'io.backend.skeleton.graphql.scalar.GraphQlScalarContractTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public final class UuidScalar { + public static java.util.UUID parse(String value) { + try { + return java.util.UUID.fromString(value); + } + catch (IllegalArgumentException ex) { + throw new graphql.schema.CoercingParseValueException("invalid UUID"); + } + } + + private UuidScalar() {} +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-schema:test --tests 'io.backend.skeleton.graphql.scalar.GraphQlScalarContractTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/scalar/UuidScalar.java' 'modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/scalar/InstantScalar.java' 'modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/scalar/DateScalar.java' 'modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/scalar/BigDecimalScalar.java' 'modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/scalar/LongScalar.java' 'modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/scalar/GraphQlScalarWiringConfigurer.java' 'modules/graphql/graphql-schema/src/test/java/io/backend/skeleton/graphql/scalar/GraphQlScalarContractTest.java' +git commit -m "feat: add graphql stable scalar wiring" +``` + +### Task 14: September 2025 `@oneOf` Input Contract + +**Files:** +- Create: `modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/schema/GraphQlOneOfPolicy.java` +- Create: `modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/schema/GraphQlOneOfInputValidator.java` +- Create: `modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/schema/GraphQlOneOfSchemaGate.java` +- Test: `modules/graphql/graphql-schema/src/test/java/io/backend/skeleton/graphql/schema/GraphQlOneOfInputValidatorTest.java` + +**Interfaces:** +- Consumes: September 2025 schema contract and GraphQL Java input coercion. +- Produces: Stable one-of input validation and schema restrictions. + +**Implementation requirements:** +- Exactly one member field must be present with a non-null value. +- Member fields remain nullable in SDL. +- Member fields cannot declare default values. +- Zero or multiple supplied fields fail before resolver execution. +- Validation errors do not echo sensitive input values. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlOneOfInputValidatorTest { + @org.junit.jupiter.api.Test + void rejectsTwoValues() { + org.assertj.core.api.Assertions.assertThatThrownBy(() -> + GraphQlOneOfInputValidator.validate( + java.util.Map.of("id", "o-1", "orderNumber", "N-1"))) + .isInstanceOf(GraphQlOneOfViolationException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-schema:test --tests 'io.backend.skeleton.graphql.schema.GraphQlOneOfInputValidatorTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public final class GraphQlOneOfInputValidator { + public static void validate(java.util.Map values) { + long present = values.values().stream().filter(java.util.Objects::nonNull).count(); + if (present != 1L) { + throw new GraphQlOneOfViolationException("exactly one value required"); + } + } + + private GraphQlOneOfInputValidator() {} +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-schema:test --tests 'io.backend.skeleton.graphql.schema.GraphQlOneOfInputValidatorTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/schema/GraphQlOneOfPolicy.java' 'modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/schema/GraphQlOneOfInputValidator.java' 'modules/graphql/graphql-schema/src/main/java/io/backend/skeleton/graphql/schema/GraphQlOneOfSchemaGate.java' 'modules/graphql/graphql-schema/src/test/java/io/backend/skeleton/graphql/schema/GraphQlOneOfInputValidatorTest.java' +git commit -m "feat: add graphql one-of input contract" +``` + +### Task 15: HTTP V1 Profile과 Media Contract + +**Files:** +- Create: `modules/graphql/graphql-http/src/main/java/io/backend/skeleton/graphql/http/GraphQlHttpProfile.java` +- Create: `modules/graphql/graphql-http/src/main/java/io/backend/skeleton/graphql/http/GraphQlMediaTypes.java` +- Create: `modules/graphql/graphql-http/src/main/java/io/backend/skeleton/graphql/http/GraphQlHttpRequestEnvelope.java` +- Create: `modules/graphql/graphql-http/src/main/java/io/backend/skeleton/graphql/http/GraphQlHttpResponsePolicy.java` +- Test: `modules/graphql/graphql-http/src/test/java/io/backend/skeleton/graphql/http/GraphQlHttpProfileTest.java` + +**Interfaces:** +- Consumes: Core policy and Spring HTTP media types. +- Produces: POST-only V1 request and preferred `application/graphql-response+json` response contract. + +**Implementation requirements:** +- Stable profile rejects GET, multipart, array batch and unapproved extensions. +- Prefer `application/graphql-response+json` while supporting legacy `application/json` responses. +- Model request errors separately from execution and field errors. +- Do not introduce draft HTTP 294 in Stable. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlHttpProfileTest { + @org.junit.jupiter.api.Test + void stableProfileRejectsGet() { + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> GraphQlHttpProfile.V1.validateMethod("GET")) + .isInstanceOf(GraphQlHttpContractException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-http:test --tests 'io.backend.skeleton.graphql.http.GraphQlHttpProfileTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public enum GraphQlHttpProfile { + V1; + + public void validateMethod(String method) { + if (!"POST".equals(method)) { + throw new GraphQlHttpContractException("POST required"); + } + } +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-http:test --tests 'io.backend.skeleton.graphql.http.GraphQlHttpProfileTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-http/src/main/java/io/backend/skeleton/graphql/http/GraphQlHttpProfile.java' 'modules/graphql/graphql-http/src/main/java/io/backend/skeleton/graphql/http/GraphQlMediaTypes.java' 'modules/graphql/graphql-http/src/main/java/io/backend/skeleton/graphql/http/GraphQlHttpRequestEnvelope.java' 'modules/graphql/graphql-http/src/main/java/io/backend/skeleton/graphql/http/GraphQlHttpResponsePolicy.java' 'modules/graphql/graphql-http/src/test/java/io/backend/skeleton/graphql/http/GraphQlHttpProfileTest.java' +git commit -m "feat: add graphql HTTP V1 profile" +``` + +### Task 16: Request Envelope·Variables·Extensions 제한 + +**Files:** +- Create: `modules/graphql/graphql-http/src/main/java/io/backend/skeleton/graphql/http/GraphQlRequestEnvelopeValidator.java` +- Create: `modules/graphql/graphql-http/src/main/java/io/backend/skeleton/graphql/http/GraphQlRequestSize.java` +- Create: `modules/graphql/graphql-http/src/main/java/io/backend/skeleton/graphql/http/GraphQlExtensionsPolicy.java` +- Create: `modules/graphql/graphql-http/src/main/java/io/backend/skeleton/graphql/http/GraphQlRequestFormatException.java` +- Test: `modules/graphql/graphql-http/src/test/java/io/backend/skeleton/graphql/http/GraphQlRequestEnvelopeValidatorTest.java` + +**Interfaces:** +- Consumes: HTTP profile and client policy. +- Produces: Pre-parse limits for body, variables, operation name and extensions. + +**Implementation requirements:** +- Reject oversized JSON before GraphQL parsing. +- `variables` and `extensions` must be JSON objects when present. +- Only registered extension keys are accepted. +- Production client policy enforces a named operation. +- Diagnostics report byte counts, never query or variable content. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlRequestEnvelopeValidatorTest { + @org.junit.jupiter.api.Test + void rejectsOversizedVariables() { + var validator = GraphQlRequestEnvelopeValidator.maxVariablesBytes(16); + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> validator.validateVariables( + "{\"value\":\"01234567890123456789\"}".getBytes( + java.nio.charset.StandardCharsets.UTF_8))) + .isInstanceOf(GraphQlRequestTooLargeException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-http:test --tests 'io.backend.skeleton.graphql.http.GraphQlRequestEnvelopeValidatorTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public final class GraphQlRequestEnvelopeValidator { + private final int maxVariablesBytes; + + private GraphQlRequestEnvelopeValidator(int maxVariablesBytes) { + this.maxVariablesBytes = maxVariablesBytes; + } + + public static GraphQlRequestEnvelopeValidator maxVariablesBytes(int value) { + return new GraphQlRequestEnvelopeValidator(value); + } + + public void validateVariables(byte[] bytes) { + if (bytes.length > maxVariablesBytes) { + throw new GraphQlRequestTooLargeException("variables too large"); + } + } +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-http:test --tests 'io.backend.skeleton.graphql.http.GraphQlRequestEnvelopeValidatorTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-http/src/main/java/io/backend/skeleton/graphql/http/GraphQlRequestEnvelopeValidator.java' 'modules/graphql/graphql-http/src/main/java/io/backend/skeleton/graphql/http/GraphQlRequestSize.java' 'modules/graphql/graphql-http/src/main/java/io/backend/skeleton/graphql/http/GraphQlExtensionsPolicy.java' 'modules/graphql/graphql-http/src/main/java/io/backend/skeleton/graphql/http/GraphQlRequestFormatException.java' 'modules/graphql/graphql-http/src/test/java/io/backend/skeleton/graphql/http/GraphQlRequestEnvelopeValidatorTest.java' +git commit -m "feat: add graphql request envelope limits" +``` + +### Task 17: HTTP Request·Execution Error Status Mapper + +**Files:** +- Create: `modules/graphql/graphql-http/src/main/java/io/backend/skeleton/graphql/http/GraphQlHttpStatusMapper.java` +- Create: `modules/graphql/graphql-http/src/main/java/io/backend/skeleton/graphql/http/GraphQlHttpOutcome.java` +- Create: `modules/graphql/graphql-http/src/main/java/io/backend/skeleton/graphql/http/GraphQlHttpResponseFactory.java` +- Test: `modules/graphql/graphql-http/src/test/java/io/backend/skeleton/graphql/http/GraphQlHttpStatusMapperTest.java` + +**Interfaces:** +- Consumes: HTTP V1 profile and GraphQL response classification. +- Produces: Stable 4xx request error and HTTP 200 execution error mapping. + +**Implementation requirements:** +- Malformed JSON, parse, validation and coercion failures map to bounded 4xx statuses. +- Execution begun with field errors maps to HTTP 200 and preserves partial data. +- Legacy JSON response mode remains compatible. +- Status mapping is versioned by HTTP profile. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlHttpStatusMapperTest { + @org.junit.jupiter.api.Test + void fieldErrorUsesHttp200() { + org.assertj.core.api.Assertions.assertThat( + GraphQlHttpStatusMapper.V1.status(GraphQlHttpOutcome.FIELD_ERROR)) + .isEqualTo(200); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-http:test --tests 'io.backend.skeleton.graphql.http.GraphQlHttpStatusMapperTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public enum GraphQlHttpStatusMapper { + V1; + + public int status(GraphQlHttpOutcome outcome) { + return switch (outcome) { + case MALFORMED_REQUEST, PARSE_ERROR, VALIDATION_ERROR -> 400; + case FIELD_ERROR, SUCCESS -> 200; + }; + } +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-http:test --tests 'io.backend.skeleton.graphql.http.GraphQlHttpStatusMapperTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-http/src/main/java/io/backend/skeleton/graphql/http/GraphQlHttpStatusMapper.java' 'modules/graphql/graphql-http/src/main/java/io/backend/skeleton/graphql/http/GraphQlHttpOutcome.java' 'modules/graphql/graphql-http/src/main/java/io/backend/skeleton/graphql/http/GraphQlHttpResponseFactory.java' 'modules/graphql/graphql-http/src/test/java/io/backend/skeleton/graphql/http/GraphQlHttpStatusMapperTest.java' +git commit -m "feat: add graphql HTTP status mapping" +``` + +### Task 18: MVC Transport Adapter와 Virtual Thread 경로 + +**Files:** +- Create: `modules/graphql/graphql-http/src/main/java/io/backend/skeleton/graphql/http/mvc/GraphQlMvcTransportAdapter.java` +- Create: `modules/graphql/graphql-http/src/main/java/io/backend/skeleton/graphql/http/mvc/GraphQlMvcExecutorPolicy.java` +- Create: `modules/graphql/graphql-http/src/main/java/io/backend/skeleton/graphql/http/mvc/GraphQlMvcAutoConfiguration.java` +- Test: `modules/graphql/graphql-http/src/test/java/io/backend/skeleton/graphql/http/mvc/GraphQlMvcTransportAdapterTest.java` + +**Interfaces:** +- Consumes: Request envelope validation, execution service and HTTP status mapper. +- Produces: Blocking MVC transport with virtual-thread or bounded executor policy. + +**Implementation requirements:** +- Use Java 21 virtual thread or a bounded platform-thread executor. +- Propagate request context and deadline. +- Cancel or close execution on client disconnect and timeout. +- Expose no WebFlux or Reactor type in the MVC public contract. +- Do not place transaction boundaries in transport code. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlMvcTransportAdapterTest { + @org.junit.jupiter.api.Test + void virtualThreadPolicyAllowsBlockingResolvers() { + org.assertj.core.api.Assertions.assertThat( + GraphQlMvcExecutorPolicy.VIRTUAL_THREAD.blockingAllowed()) + .isTrue(); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-http:test --tests 'io.backend.skeleton.graphql.http.mvc.GraphQlMvcTransportAdapterTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public enum GraphQlMvcExecutorPolicy { + VIRTUAL_THREAD(true), + BOUNDED_PLATFORM_THREAD(true); + + private final boolean blockingAllowed; + + GraphQlMvcExecutorPolicy(boolean blockingAllowed) { + this.blockingAllowed = blockingAllowed; + } + + public boolean blockingAllowed() { + return blockingAllowed; + } +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-http:test --tests 'io.backend.skeleton.graphql.http.mvc.GraphQlMvcTransportAdapterTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-http/src/main/java/io/backend/skeleton/graphql/http/mvc/GraphQlMvcTransportAdapter.java' 'modules/graphql/graphql-http/src/main/java/io/backend/skeleton/graphql/http/mvc/GraphQlMvcExecutorPolicy.java' 'modules/graphql/graphql-http/src/main/java/io/backend/skeleton/graphql/http/mvc/GraphQlMvcAutoConfiguration.java' 'modules/graphql/graphql-http/src/test/java/io/backend/skeleton/graphql/http/mvc/GraphQlMvcTransportAdapterTest.java' +git commit -m "feat: add graphql mvc transport adapter" +``` + +### Task 19: WebFlux Transport Adapter와 Event-loop Guard + +**Files:** +- Create: `modules/graphql/graphql-http/src/main/java/io/backend/skeleton/graphql/http/webflux/GraphQlWebFluxTransportAdapter.java` +- Create: `modules/graphql/graphql-http/src/main/java/io/backend/skeleton/graphql/http/webflux/GraphQlEventLoopGuard.java` +- Create: `modules/graphql/graphql-http/src/main/java/io/backend/skeleton/graphql/http/webflux/GraphQlWebFluxAutoConfiguration.java` +- Test: `modules/graphql/graphql-http/src/test/java/io/backend/skeleton/graphql/http/webflux/GraphQlEventLoopGuardTest.java` + +**Interfaces:** +- Consumes: Request envelope validation, execution service and Reactor. +- Produces: Reactive transport with cancellation and explicit blocking detection. + +**Implementation requirements:** +- Reject blocking resolver execution on an event-loop thread unless an approved scheduler bridge is registered. +- Propagate Reactor Context to `GraphQlRequestContext`. +- Release response buffers on cancellation. +- Cancellation reaches reactive DataFetchers and downstream publishers. +- Do not call `.block()` in WebFlux infrastructure. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlEventLoopGuardTest { + @org.junit.jupiter.api.Test + void blockingResolverIsRejectedOnEventLoop() { + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> GraphQlEventLoopGuard.verify( + ResolverExecutionType.BLOCKING, true, false)) + .isInstanceOf(GraphQlExecutionProfileException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-http:test --tests 'io.backend.skeleton.graphql.http.webflux.GraphQlEventLoopGuardTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public final class GraphQlEventLoopGuard { + public static void verify( + ResolverExecutionType type, + boolean eventLoopThread, + boolean approvedBridge) { + + if (eventLoopThread + && type == ResolverExecutionType.BLOCKING + && !approvedBridge) { + throw new GraphQlExecutionProfileException( + "blocking resolver on event loop"); + } + } + + private GraphQlEventLoopGuard() {} +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-http:test --tests 'io.backend.skeleton.graphql.http.webflux.GraphQlEventLoopGuardTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-http/src/main/java/io/backend/skeleton/graphql/http/webflux/GraphQlWebFluxTransportAdapter.java' 'modules/graphql/graphql-http/src/main/java/io/backend/skeleton/graphql/http/webflux/GraphQlEventLoopGuard.java' 'modules/graphql/graphql-http/src/main/java/io/backend/skeleton/graphql/http/webflux/GraphQlWebFluxAutoConfiguration.java' 'modules/graphql/graphql-http/src/test/java/io/backend/skeleton/graphql/http/webflux/GraphQlEventLoopGuardTest.java' +git commit -m "feat: add graphql webflux event-loop guard" +``` + +### Task 20: Execution Interceptor 순서와 Policy Pipeline + +**Files:** +- Create: `modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/execution/GraphQlExecutionPipeline.java` +- Create: `modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/execution/GraphQlExecutionStage.java` +- Create: `modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/execution/GraphQlExecutionPipelineValidator.java` +- Test: `modules/graphql/graphql-execution/src/test/java/io/backend/skeleton/graphql/execution/GraphQlExecutionPipelineTest.java` + +**Interfaces:** +- Consumes: Request context, HTTP request envelope and Spring WebGraphQlInterceptor. +- Produces: Immutable ordered pipeline from context through policy, cost and execution. + +**Implementation requirements:** +- Context is established before authorization. +- Persisted lookup precedes parse when an operation ID is supplied. +- Cost and authorization run before resolver execution. +- Custom interceptors cannot bypass required stages. +- Pipeline diagnostics expose stage names only. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlExecutionPipelineTest { + @org.junit.jupiter.api.Test + void costRunsBeforeExecution() { + GraphQlExecutionPipeline pipeline = GraphQlExecutionPipeline.stable(); + org.assertj.core.api.Assertions.assertThat( + pipeline.indexOf(GraphQlExecutionStage.COST)) + .isLessThan(pipeline.indexOf(GraphQlExecutionStage.EXECUTE)); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-execution:test --tests 'io.backend.skeleton.graphql.execution.GraphQlExecutionPipelineTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GraphQlExecutionPipeline( + java.util.List stages) { + + public GraphQlExecutionPipeline { + stages = java.util.List.copyOf(stages); + } + + public int indexOf(GraphQlExecutionStage stage) { + return stages.indexOf(stage); + } + + public static GraphQlExecutionPipeline stable() { + return new GraphQlExecutionPipeline(java.util.List.of( + GraphQlExecutionStage.CONTEXT, + GraphQlExecutionStage.AUTHORIZATION, + GraphQlExecutionStage.PARSE_VALIDATE, + GraphQlExecutionStage.COST, + GraphQlExecutionStage.EXECUTE)); + } +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-execution:test --tests 'io.backend.skeleton.graphql.execution.GraphQlExecutionPipelineTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/execution/GraphQlExecutionPipeline.java' 'modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/execution/GraphQlExecutionStage.java' 'modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/execution/GraphQlExecutionPipelineValidator.java' 'modules/graphql/graphql-execution/src/test/java/io/backend/skeleton/graphql/execution/GraphQlExecutionPipelineTest.java' +git commit -m "feat: add graphql execution policy pipeline" +``` + +### Task 21: Execution Profile과 Resolver Catalog 검증 + +**Files:** +- Create: `modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/execution/GraphQlExecutionProfile.java` +- Create: `modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/execution/GraphQlResolverCatalog.java` +- Create: `modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/execution/GraphQlResolverDescriptor.java` +- Create: `modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/execution/GraphQlExecutionProfileValidator.java` +- Test: `modules/graphql/graphql-execution/src/test/java/io/backend/skeleton/graphql/execution/GraphQlExecutionProfileValidatorTest.java` + +**Interfaces:** +- Consumes: Operation policy and runtime transport profile. +- Produces: Blocking, reactive and controlled mixed profile compatibility checks. + +**Implementation requirements:** +- `REACTIVE_WEBFLUX` rejects unbridged blocking resolvers. +- `BLOCKING_MVC` accepts reactive return only through an explicit adapter. +- `STREAM` requires a subscription and a Publisher return type. +- Unknown resolver descriptors fail startup. +- Resolver catalog entries use bounded schema coordinates. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlExecutionProfileValidatorTest { + @org.junit.jupiter.api.Test + void reactiveProfileRejectsBlockingDescriptor() { + var descriptor = new GraphQlResolverDescriptor( + "Order.total", ResolverExecutionType.BLOCKING, false); + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> GraphQlExecutionProfileValidator.validate( + GraphQlExecutionProfile.REACTIVE_WEBFLUX, descriptor)) + .isInstanceOf(GraphQlExecutionProfileException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-execution:test --tests 'io.backend.skeleton.graphql.execution.GraphQlExecutionProfileValidatorTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public enum GraphQlExecutionProfile { + BLOCKING_MVC, + REACTIVE_WEBFLUX, + MIXED_CONTROLLED +} + +public final class GraphQlExecutionProfileValidator { + public static void validate( + GraphQlExecutionProfile profile, + GraphQlResolverDescriptor descriptor) { + + if (profile == GraphQlExecutionProfile.REACTIVE_WEBFLUX + && descriptor.executionType() == ResolverExecutionType.BLOCKING + && !descriptor.approvedBridge()) { + throw new GraphQlExecutionProfileException( + "blocking resolver requires bridge"); + } + } +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-execution:test --tests 'io.backend.skeleton.graphql.execution.GraphQlExecutionProfileValidatorTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/execution/GraphQlExecutionProfile.java' 'modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/execution/GraphQlResolverCatalog.java' 'modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/execution/GraphQlResolverDescriptor.java' 'modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/execution/GraphQlExecutionProfileValidator.java' 'modules/graphql/graphql-execution/src/test/java/io/backend/skeleton/graphql/execution/GraphQlExecutionProfileValidatorTest.java' +git commit -m "feat: add graphql execution profile validation" +``` + +### Task 22: Request Timeout·Resolver Budget·Cancellation + +**Files:** +- Create: `modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/execution/GraphQlTimeoutPolicy.java` +- Create: `modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/execution/GraphQlCancellation.java` +- Create: `modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/execution/GraphQlResolverBudget.java` +- Create: `modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/execution/GraphQlDeadlinePropagator.java` +- Test: `modules/graphql/graphql-execution/src/test/java/io/backend/skeleton/graphql/execution/GraphQlTimeoutPolicyTest.java` + +**Interfaces:** +- Consumes: Request deadline, execution pipeline and Spring timeout interceptor. +- Produces: Layered request, resolver, DataLoader and shutdown deadlines with cancellation. + +**Implementation requirements:** +- No child deadline exceeds the parent remaining time. +- Execution timeout maps to a stable error code. +- Reactive timeout cancels downstream publishers. +- Blocking work uses actual transport/database timeouts and does not assume interrupt alone is sufficient. +- Normal request timeout does not govern subscription lifetime. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlTimeoutPolicyTest { + @org.junit.jupiter.api.Test + void childBudgetCannotExceedParent() { + GraphQlTimeoutPolicy policy = + new GraphQlTimeoutPolicy(java.time.Duration.ofSeconds(2)); + org.assertj.core.api.Assertions.assertThat( + policy.child(java.time.Duration.ofSeconds(5))) + .isEqualTo(java.time.Duration.ofSeconds(2)); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-execution:test --tests 'io.backend.skeleton.graphql.execution.GraphQlTimeoutPolicyTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GraphQlTimeoutPolicy(java.time.Duration remaining) { + public GraphQlTimeoutPolicy { + if (remaining.isZero() || remaining.isNegative()) { + throw new IllegalArgumentException("remaining time must be positive"); + } + } + + public java.time.Duration child(java.time.Duration requested) { + return requested.compareTo(remaining) < 0 ? requested : remaining; + } +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-execution:test --tests 'io.backend.skeleton.graphql.execution.GraphQlTimeoutPolicyTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/execution/GraphQlTimeoutPolicy.java' 'modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/execution/GraphQlCancellation.java' 'modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/execution/GraphQlResolverBudget.java' 'modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/execution/GraphQlDeadlinePropagator.java' 'modules/graphql/graphql-execution/src/test/java/io/backend/skeleton/graphql/execution/GraphQlTimeoutPolicyTest.java' +git commit -m "feat: add graphql timeout and cancellation policy" +``` + +### Task 23: Resolver Return Type와 Transport DTO Guard + +**Files:** +- Create: `modules/graphql/graphql-controller/src/main/java/io/backend/skeleton/graphql/architecture/GraphQlReturnTypePolicy.java` +- Create: `modules/graphql/graphql-controller/src/main/java/io/backend/skeleton/graphql/architecture/GraphQlInputTypePolicy.java` +- Create: `modules/graphql/graphql-controller/src/main/java/io/backend/skeleton/graphql/architecture/GraphQlControllerInspector.java` +- Test: `modules/graphql/graphql-controller/src/test/java/io/backend/skeleton/graphql/architecture/GraphQlControllerInspectorTest.java` + +**Interfaces:** +- Consumes: Resolver catalog and reflection/ArchUnit. +- Produces: Startup inspection for DTO, read model, connection, mutation payload and publisher types. + +**Implementation requirements:** +- Block JPA entity, Mongo document, provider SDK and unrestricted map return types. +- Block direct binding of GraphQL input to persistence types. +- Report schema coordinate and Java method. +- Publisher return type is allowed only for subscriptions. +- Generated transport DTOs remain separate from domain types. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlControllerInspectorTest { + @org.junit.jupiter.api.Test + void mapReturnTypeIsRejected() throws Exception { + java.lang.reflect.Method method = + BadController.class.getDeclaredMethod("query"); + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> GraphQlControllerInspector.inspect(method)) + .isInstanceOf(GraphQlControllerContractException.class); + } + + static class BadController { + java.util.Map query() { + return java.util.Map.of(); + } + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-controller:test --tests 'io.backend.skeleton.graphql.architecture.GraphQlControllerInspectorTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public final class GraphQlReturnTypePolicy { + public static boolean allowed(Class type) { + return !java.util.Map.class.isAssignableFrom(type) + && !type.isAnnotationPresent(jakarta.persistence.Entity.class); + } + + private GraphQlReturnTypePolicy() {} +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-controller:test --tests 'io.backend.skeleton.graphql.architecture.GraphQlControllerInspectorTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-controller/src/main/java/io/backend/skeleton/graphql/architecture/GraphQlReturnTypePolicy.java' 'modules/graphql/graphql-controller/src/main/java/io/backend/skeleton/graphql/architecture/GraphQlInputTypePolicy.java' 'modules/graphql/graphql-controller/src/main/java/io/backend/skeleton/graphql/architecture/GraphQlControllerInspector.java' 'modules/graphql/graphql-controller/src/test/java/io/backend/skeleton/graphql/architecture/GraphQlControllerInspectorTest.java' +git commit -m "test: enforce graphql transport DTO contract" +``` + +### Task 24: GraphQL Error Wire Model + +**Files:** +- Create: `modules/graphql/graphql-error/src/main/java/io/backend/skeleton/graphql/error/GraphQlErrorCategory.java` +- Create: `modules/graphql/graphql-error/src/main/java/io/backend/skeleton/graphql/error/GraphQlWireError.java` +- Create: `modules/graphql/graphql-error/src/main/java/io/backend/skeleton/graphql/error/GraphQlErrorContext.java` +- Create: `modules/graphql/graphql-error/src/main/java/io/backend/skeleton/graphql/error/GraphQlErrorCode.java` +- Test: `modules/graphql/graphql-error/src/test/java/io/backend/skeleton/graphql/error/GraphQlWireErrorTest.java` + +**Interfaces:** +- Consumes: Core identifiers and GraphQL error model. +- Produces: Stable allowlisted error extensions without internal diagnostics. + +**Implementation requirements:** +- Expose only code, category, retryable, executionId, safe constraint and logical field. +- Client message is independent of exception text. +- Path and location remain GraphQL top-level error fields. +- Error codes use a bounded catalog. +- Extension maps are immutable. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlWireErrorTest { + @org.junit.jupiter.api.Test + void internalErrorHasOnlyAllowedExtensions() { + GraphQlWireError error = GraphQlWireError.internal("exec-1"); + org.assertj.core.api.Assertions.assertThat(error.extensions()) + .containsOnlyKeys( + "code", "category", "retryable", "executionId"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-error:test --tests 'io.backend.skeleton.graphql.error.GraphQlWireErrorTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GraphQlWireError( + String message, + java.util.Map extensions) { + + public GraphQlWireError { + extensions = java.util.Map.copyOf(extensions); + } + + public static GraphQlWireError internal(String executionId) { + return new GraphQlWireError( + "요청을 처리할 수 없습니다.", + java.util.Map.of( + "code", "INTERNAL_ERROR", + "category", "INTERNAL", + "retryable", false, + "executionId", executionId)); + } +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-error:test --tests 'io.backend.skeleton.graphql.error.GraphQlWireErrorTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-error/src/main/java/io/backend/skeleton/graphql/error/GraphQlErrorCategory.java' 'modules/graphql/graphql-error/src/main/java/io/backend/skeleton/graphql/error/GraphQlWireError.java' 'modules/graphql/graphql-error/src/main/java/io/backend/skeleton/graphql/error/GraphQlErrorContext.java' 'modules/graphql/graphql-error/src/main/java/io/backend/skeleton/graphql/error/GraphQlErrorCode.java' 'modules/graphql/graphql-error/src/test/java/io/backend/skeleton/graphql/error/GraphQlWireErrorTest.java' +git commit -m "feat: add graphql error wire contract" +``` + +### Task 25: Exception Resolver와 Partial Data Contract + +**Files:** +- Create: `modules/graphql/graphql-error/src/main/java/io/backend/skeleton/graphql/error/GraphQlExceptionResolver.java` +- Create: `modules/graphql/graphql-error/src/main/java/io/backend/skeleton/graphql/error/GraphQlRequestErrorMapper.java` +- Create: `modules/graphql/graphql-error/src/main/java/io/backend/skeleton/graphql/error/GraphQlSubscriptionExceptionResolver.java` +- Create: `modules/graphql/graphql-error/src/main/java/io/backend/skeleton/graphql/error/GraphQlInternalErrorMasker.java` +- Test: `modules/graphql/graphql-error/src/test/java/io/backend/skeleton/graphql/error/GraphQlExceptionResolverTest.java` + +**Interfaces:** +- Consumes: Wire error model and Spring exception resolution APIs. +- Produces: Request, field, business and internal failure mapping with partial data preservation. + +**Implementation requirements:** +- Expected business outcomes remain typed data when configured. +- Unresolved execution failures become opaque `INTERNAL_ERROR`. +- Parse and validation errors use a request mapper rather than a DataFetcher resolver. +- Subscription post-start failures use a dedicated resolver. +- SQL, queries, URLs, provider body and stack trace never reach the client. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlExceptionResolverTest { + @org.junit.jupiter.api.Test + void internalExceptionIsMasked() { + GraphQlWireError error = GraphQlExceptionResolver.defaults() + .resolve( + new RuntimeException("select secret from users"), + GraphQlErrorContext.test()); + org.assertj.core.api.Assertions.assertThat(error.message()) + .doesNotContain("select", "users"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-error:test --tests 'io.backend.skeleton.graphql.error.GraphQlExceptionResolverTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public final class GraphQlExceptionResolver { + public GraphQlWireError resolve( + Throwable failure, + GraphQlErrorContext context) { + return GraphQlWireError.internal(context.executionId()); + } + + public static GraphQlExceptionResolver defaults() { + return new GraphQlExceptionResolver(); + } +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-error:test --tests 'io.backend.skeleton.graphql.error.GraphQlExceptionResolverTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-error/src/main/java/io/backend/skeleton/graphql/error/GraphQlExceptionResolver.java' 'modules/graphql/graphql-error/src/main/java/io/backend/skeleton/graphql/error/GraphQlRequestErrorMapper.java' 'modules/graphql/graphql-error/src/main/java/io/backend/skeleton/graphql/error/GraphQlSubscriptionExceptionResolver.java' 'modules/graphql/graphql-error/src/main/java/io/backend/skeleton/graphql/error/GraphQlInternalErrorMasker.java' 'modules/graphql/graphql-error/src/test/java/io/backend/skeleton/graphql/error/GraphQlExceptionResolverTest.java' +git commit -m "feat: add graphql exception resolvers" +``` + +### Task 26: Null Propagation Golden Contract + +**Files:** +- Create: `modules/graphql/graphql-error/src/main/java/io/backend/skeleton/graphql/error/GraphQlNullabilityContract.java` +- Create: `modules/graphql/graphql-error/src/main/java/io/backend/skeleton/graphql/error/GraphQlPartialResponseFixture.java` +- Create: `modules/graphql/graphql-error/src/main/java/io/backend/skeleton/graphql/error/GraphQlFailureBoundary.java` +- Test: `modules/graphql/graphql-error/src/test/java/io/backend/skeleton/graphql/error/GraphQlNullPropagationContractTest.java` + +**Interfaces:** +- Consumes: Schema contract and execution testkit. +- Produces: Golden contracts for nullable and non-null failure propagation. + +**Implementation requirements:** +- Snapshot expected partial data and error paths. +- Adding non-null requires an explicit contract fixture. +- Authorization redaction cannot silently violate a non-null field. +- External enrichment fields default to nullable. +- List nullability and element nullability are tested independently. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlNullPropagationContractTest { + @org.junit.jupiter.api.Test + void nullableChildPreservesParent() { + GraphQlPartialResponseFixture response = + GraphQlPartialResponseFixture.nullableChildFailure(); + org.assertj.core.api.Assertions.assertThat( + response.dataPath("order.id")).isEqualTo("o-1"); + org.assertj.core.api.Assertions.assertThat( + response.dataPath("order.payment")).isNull(); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-error:test --tests 'io.backend.skeleton.graphql.error.GraphQlNullPropagationContractTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GraphQlNullabilityContract( + String coordinate, + boolean nonNull, + GraphQlFailureBoundary boundary) { +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-error:test --tests 'io.backend.skeleton.graphql.error.GraphQlNullPropagationContractTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-error/src/main/java/io/backend/skeleton/graphql/error/GraphQlNullabilityContract.java' 'modules/graphql/graphql-error/src/main/java/io/backend/skeleton/graphql/error/GraphQlPartialResponseFixture.java' 'modules/graphql/graphql-error/src/main/java/io/backend/skeleton/graphql/error/GraphQlFailureBoundary.java' 'modules/graphql/graphql-error/src/test/java/io/backend/skeleton/graphql/error/GraphQlNullPropagationContractTest.java' +git commit -m "test: add graphql null propagation contract" +``` + +### Task 27: Authentication Context Factory + +**Files:** +- Create: `modules/graphql/graphql-security/src/main/java/io/backend/skeleton/graphql/security/GraphQlAuthenticationContextFactory.java` +- Create: `modules/graphql/graphql-security/src/main/java/io/backend/skeleton/graphql/security/GraphQlAuthenticatedPrincipal.java` +- Create: `modules/graphql/graphql-security/src/main/java/io/backend/skeleton/graphql/security/GraphQlClientProfileResolver.java` +- Test: `modules/graphql/graphql-security/src/test/java/io/backend/skeleton/graphql/security/GraphQlAuthenticationContextFactoryTest.java` + +**Interfaces:** +- Consumes: Spring Security authentication and core request context. +- Produces: Trusted conversion from HTTP or session principal to immutable request context. + +**Implementation requirements:** +- Reject unauthenticated requests for protected profiles. +- Resolve client profile from trusted credential metadata, not variables. +- Do not copy access tokens, cookies or raw claims into context. +- Locale and tenant resolution are explicit policies. +- Authentication failures happen before GraphQL resolver execution. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlAuthenticationContextFactoryTest { + @org.junit.jupiter.api.Test + void principalTenantIsAuthoritative() { + GraphQlRequestContext context = + GraphQlAuthenticationContextFactory.testContext("tenant-a"); + org.assertj.core.api.Assertions.assertThat( + context.tenant().value()).isEqualTo("tenant-a"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-security:test --tests 'io.backend.skeleton.graphql.security.GraphQlAuthenticationContextFactoryTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public final class GraphQlAuthenticationContextFactory { + public GraphQlRequestContext create( + GraphQlAuthenticatedPrincipal principal, + GraphQlDeadline deadline) { + + return new GraphQlRequestContext( + principal.actor(), + principal.tenant(), + principal.clientProfile(), + java.util.Locale.ROOT, + new GraphQlOperationId("pending"), + principal.traceId(), + deadline); + } +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-security:test --tests 'io.backend.skeleton.graphql.security.GraphQlAuthenticationContextFactoryTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-security/src/main/java/io/backend/skeleton/graphql/security/GraphQlAuthenticationContextFactory.java' 'modules/graphql/graphql-security/src/main/java/io/backend/skeleton/graphql/security/GraphQlAuthenticatedPrincipal.java' 'modules/graphql/graphql-security/src/main/java/io/backend/skeleton/graphql/security/GraphQlClientProfileResolver.java' 'modules/graphql/graphql-security/src/test/java/io/backend/skeleton/graphql/security/GraphQlAuthenticationContextFactoryTest.java' +git commit -m "feat: add graphql authentication context" +``` + +### Task 28: Operation·Field·Object Authorization Boundary + +**Files:** +- Create: `modules/graphql/graphql-security/src/main/java/io/backend/skeleton/graphql/security/GraphQlAuthorizationPolicy.java` +- Create: `modules/graphql/graphql-security/src/main/java/io/backend/skeleton/graphql/security/GraphQlAuthorizationDecision.java` +- Create: `modules/graphql/graphql-security/src/main/java/io/backend/skeleton/graphql/security/GraphQlAuthorizationInterceptor.java` +- Create: `modules/graphql/graphql-security/src/main/java/io/backend/skeleton/graphql/security/GraphQlObjectAuthorizationPort.java` +- Test: `modules/graphql/graphql-security/src/test/java/io/backend/skeleton/graphql/security/GraphQlAuthorizationPolicyTest.java` + +**Interfaces:** +- Consumes: Request context, schema coordinate and Application authorization ports. +- Produces: Layered operation, field/use-case and object authorization decisions. + +**Implementation requirements:** +- Field visibility never counts as authorization. +- Object authorization uses an Application port, not a repository from platform code. +- Denied decisions use stable error codes. +- Existence-hiding policy is configurable per coordinate. +- Batch loader authorization is defined for every key. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlAuthorizationPolicyTest { + @org.junit.jupiter.api.Test + void hiddenFieldStillRequiresAuthorization() { + GraphQlAuthorizationDecision decision = + GraphQlAuthorizationPolicy.deny("ORDER_READ_DENIED"); + org.assertj.core.api.Assertions.assertThat( + decision.allowed()).isFalse(); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-security:test --tests 'io.backend.skeleton.graphql.security.GraphQlAuthorizationPolicyTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GraphQlAuthorizationDecision( + boolean allowed, + String code) { + + public static GraphQlAuthorizationDecision deny(String code) { + return new GraphQlAuthorizationDecision(false, code); + } +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-security:test --tests 'io.backend.skeleton.graphql.security.GraphQlAuthorizationPolicyTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-security/src/main/java/io/backend/skeleton/graphql/security/GraphQlAuthorizationPolicy.java' 'modules/graphql/graphql-security/src/main/java/io/backend/skeleton/graphql/security/GraphQlAuthorizationDecision.java' 'modules/graphql/graphql-security/src/main/java/io/backend/skeleton/graphql/security/GraphQlAuthorizationInterceptor.java' 'modules/graphql/graphql-security/src/main/java/io/backend/skeleton/graphql/security/GraphQlObjectAuthorizationPort.java' 'modules/graphql/graphql-security/src/test/java/io/backend/skeleton/graphql/security/GraphQlAuthorizationPolicyTest.java' +git commit -m "feat: add graphql authorization policy" +``` + +### Task 29: Tenant Isolation과 Context Propagation + +**Files:** +- Create: `modules/graphql/graphql-security/src/main/java/io/backend/skeleton/graphql/security/GraphQlTenantIsolationPolicy.java` +- Create: `modules/graphql/graphql-security/src/main/java/io/backend/skeleton/graphql/security/GraphQlContextPropagator.java` +- Create: `modules/graphql/graphql-security/src/main/java/io/backend/skeleton/graphql/security/GraphQlBatchContext.java` +- Create: `modules/graphql/graphql-security/src/main/java/io/backend/skeleton/graphql/security/GraphQlContextCleanup.java` +- Test: `modules/graphql/graphql-security/src/test/java/io/backend/skeleton/graphql/security/GraphQlTenantIsolationPolicyTest.java` + +**Interfaces:** +- Consumes: Trusted request context, executor bridge and Reactor Context. +- Produces: Fail-closed tenant propagation across resolver, DataLoader, async and reactive work. + +**Implementation requirements:** +- Missing tenant context fails protected operations. +- Tenant cannot be sourced from a GraphQL argument. +- DataLoader keys are not cached across tenant boundaries. +- Thread and Reactor context are cleared after execution. +- Context diagnostics never contain raw tenant identifiers in metrics. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlTenantIsolationPolicyTest { + @org.junit.jupiter.api.Test + void missingTenantFailsClosed() { + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> GraphQlTenantIsolationPolicy.require(null)) + .isInstanceOf(GraphQlTenantIsolationException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-security:test --tests 'io.backend.skeleton.graphql.security.GraphQlTenantIsolationPolicyTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public final class GraphQlTenantIsolationPolicy { + public static TenantContext require(TenantContext tenant) { + if (tenant == null) { + throw new GraphQlTenantIsolationException( + "tenant context required"); + } + return tenant; + } + + private GraphQlTenantIsolationPolicy() {} +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-security:test --tests 'io.backend.skeleton.graphql.security.GraphQlTenantIsolationPolicyTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-security/src/main/java/io/backend/skeleton/graphql/security/GraphQlTenantIsolationPolicy.java' 'modules/graphql/graphql-security/src/main/java/io/backend/skeleton/graphql/security/GraphQlContextPropagator.java' 'modules/graphql/graphql-security/src/main/java/io/backend/skeleton/graphql/security/GraphQlBatchContext.java' 'modules/graphql/graphql-security/src/main/java/io/backend/skeleton/graphql/security/GraphQlContextCleanup.java' 'modules/graphql/graphql-security/src/test/java/io/backend/skeleton/graphql/security/GraphQlTenantIsolationPolicyTest.java' +git commit -m "feat: add graphql tenant isolation" +``` + +### Task 30: Parser Character·Token·Grammar 제한 + +**Files:** +- Create: `modules/graphql/graphql-cost-control/src/main/java/io/backend/skeleton/graphql/cost/GraphQlParserLimits.java` +- Create: `modules/graphql/graphql-cost-control/src/main/java/io/backend/skeleton/graphql/cost/GraphQlParserLimitPolicy.java` +- Create: `modules/graphql/graphql-cost-control/src/main/java/io/backend/skeleton/graphql/cost/GraphQlParserRejectedException.java` +- Create: `modules/graphql/graphql-cost-control/src/main/java/io/backend/skeleton/graphql/cost/GraphQlParserOptionsFactory.java` +- Test: `modules/graphql/graphql-cost-control/src/test/java/io/backend/skeleton/graphql/cost/GraphQlParserLimitPolicyTest.java` + +**Interfaces:** +- Consumes: `GraphQlClientPolicy`에서 선택된 문서 크기·token·grammar 제한과 GraphQL Java parser options. +- Produces: 실행 전에 문서 문자 수, token 수, whitespace token 수, grammar depth를 거부하는 parser gate. + +**Implementation requirements:** +- Library ceiling을 public API의 business limit로 그대로 사용하지 않고 client profile 값으로 제한한다. +- 문서가 parser에 전달되기 전에 byte·character 제한을 검사한다. +- Token·whitespace·grammar depth 제한은 GraphQL Java parser options에 정확히 매핑한다. +- 거부 결과는 resolver를 실행하지 않고 안정적인 `GRAPHQL_DOCUMENT_LIMIT_EXCEEDED` request error로 변환한다. +- Raw document를 로그와 metric에 기록하지 않는다. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlParserLimitPolicyTest { + @org.junit.jupiter.api.Test + void rejectsDocumentBeforeParserWhenCharacterBudgetIsExceeded() { + var limits = new GraphQlParserLimits(32, 20, 40, 8); + var policy = new GraphQlParserLimitPolicy(limits); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> policy.verifyDocument("query TooLong { " + "x".repeat(64) + " }")) + .isInstanceOf(GraphQlParserRejectedException.class) + .hasMessageContaining("CHARACTERS"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-cost-control:test --tests 'io.backend.skeleton.graphql.cost.GraphQlParserLimitPolicyTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GraphQlParserLimits( + int maxCharacters, + int maxTokens, + int maxWhitespaceTokens, + int maxGrammarRuleDepth) { + + public GraphQlParserLimits { + if (maxCharacters < 1 || maxTokens < 1 + || maxWhitespaceTokens < 1 || maxGrammarRuleDepth < 1) { + throw new IllegalArgumentException( + "all parser limits must be positive"); + } + } +} + +public final class GraphQlParserLimitPolicy { + private final GraphQlParserLimits limits; + + public GraphQlParserLimitPolicy(GraphQlParserLimits limits) { + this.limits = java.util.Objects.requireNonNull(limits); + } + + public void verifyDocument(String document) { + if (document == null || document.length() > limits.maxCharacters()) { + throw GraphQlParserRejectedException.characters( + document == null ? 0 : document.length(), + limits.maxCharacters()); + } + } +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-cost-control:test --tests 'io.backend.skeleton.graphql.cost.GraphQlParserLimitPolicyTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-cost-control/src/main/java/io/backend/skeleton/graphql/cost/GraphQlParserLimits.java' 'modules/graphql/graphql-cost-control/src/main/java/io/backend/skeleton/graphql/cost/GraphQlParserLimitPolicy.java' 'modules/graphql/graphql-cost-control/src/main/java/io/backend/skeleton/graphql/cost/GraphQlParserRejectedException.java' 'modules/graphql/graphql-cost-control/src/main/java/io/backend/skeleton/graphql/cost/GraphQlParserOptionsFactory.java' 'modules/graphql/graphql-cost-control/src/test/java/io/backend/skeleton/graphql/cost/GraphQlParserLimitPolicyTest.java' +git commit -m "feat: add graphql parser limit policy" +``` + +### Task 31: Selection Depth·Field·Alias·Fragment 구조 제한 + +**Files:** +- Create: `modules/graphql/graphql-cost-control/src/main/java/io/backend/skeleton/graphql/cost/GraphQlStructuralLimits.java` +- Create: `modules/graphql/graphql-cost-control/src/main/java/io/backend/skeleton/graphql/cost/GraphQlDocumentShape.java` +- Create: `modules/graphql/graphql-cost-control/src/main/java/io/backend/skeleton/graphql/cost/GraphQlDocumentShapeAnalyzer.java` +- Create: `modules/graphql/graphql-cost-control/src/main/java/io/backend/skeleton/graphql/cost/GraphQlStructuralLimitPolicy.java` +- Create: `modules/graphql/graphql-cost-control/src/main/java/io/backend/skeleton/graphql/cost/GraphQlStructuralLimitViolation.java` +- Test: `modules/graphql/graphql-cost-control/src/test/java/io/backend/skeleton/graphql/cost/GraphQlStructuralLimitPolicyTest.java` + +**Interfaces:** +- Consumes: Parser를 통과한 GraphQL `Document`, 선택된 `GraphQlClientPolicy`와 fragment graph. +- Produces: Depth, field, alias, fragment, spread, operation count 및 input nesting의 bounded 구조 검사. + +**Implementation requirements:** +- Fragment cycle은 GraphQL validation과 별도로 analyzer recursion을 무한 반복시키지 않는다. +- Alias 수와 field 수를 별도로 계산해 alias bomb를 탐지한다. +- Introspection field는 client profile 허용 여부에 따라 구조 검사 단계에서 거부한다. +- 한 문서에 여러 operation이 있으면 `operationName` 선택 전 전체 문서 비용을 우회하지 못하도록 operation count를 검증한다. +- 구조 계산은 document 크기에 대해 선형 또는 bounded하게 동작한다. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlStructuralLimitPolicyTest { + @org.junit.jupiter.api.Test + void rejectsAliasBombEvenWhenDepthIsSmall() { + var shape = new GraphQlDocumentShape( + 2, 40, 35, 0, 0, 1, 1); + var limits = new GraphQlStructuralLimits( + 8, 100, 10, 20, 40, 2, 8); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> new GraphQlStructuralLimitPolicy(limits).verify(shape)) + .isInstanceOf(GraphQlStructuralLimitViolation.class) + .hasMessageContaining("ALIASES"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-cost-control:test --tests 'io.backend.skeleton.graphql.cost.GraphQlStructuralLimitPolicyTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GraphQlDocumentShape( + int depth, + int fieldCount, + int aliasCount, + int fragmentCount, + int fragmentSpreadCount, + int operationCount, + int inputNestingDepth) { +} + +public record GraphQlStructuralLimits( + int maxDepth, + int maxFields, + int maxAliases, + int maxFragments, + int maxFragmentSpreads, + int maxOperations, + int maxInputNestingDepth) { +} + +public final class GraphQlStructuralLimitPolicy { + private final GraphQlStructuralLimits limits; + + public GraphQlStructuralLimitPolicy(GraphQlStructuralLimits limits) { + this.limits = java.util.Objects.requireNonNull(limits); + } + + public void verify(GraphQlDocumentShape shape) { + if (shape.aliasCount() > limits.maxAliases()) { + throw GraphQlStructuralLimitViolation.of( + "ALIASES", shape.aliasCount(), limits.maxAliases()); + } + if (shape.depth() > limits.maxDepth() + || shape.fieldCount() > limits.maxFields() + || shape.operationCount() > limits.maxOperations()) { + throw GraphQlStructuralLimitViolation.of( + "DOCUMENT_SHAPE", shape.fieldCount(), limits.maxFields()); + } + } +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-cost-control:test --tests 'io.backend.skeleton.graphql.cost.GraphQlStructuralLimitPolicyTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-cost-control/src/main/java/io/backend/skeleton/graphql/cost/GraphQlStructuralLimits.java' 'modules/graphql/graphql-cost-control/src/main/java/io/backend/skeleton/graphql/cost/GraphQlDocumentShape.java' 'modules/graphql/graphql-cost-control/src/main/java/io/backend/skeleton/graphql/cost/GraphQlDocumentShapeAnalyzer.java' 'modules/graphql/graphql-cost-control/src/main/java/io/backend/skeleton/graphql/cost/GraphQlStructuralLimitPolicy.java' 'modules/graphql/graphql-cost-control/src/main/java/io/backend/skeleton/graphql/cost/GraphQlStructuralLimitViolation.java' 'modules/graphql/graphql-cost-control/src/test/java/io/backend/skeleton/graphql/cost/GraphQlStructuralLimitPolicyTest.java' +git commit -m "feat: add graphql structural limits" +``` + +### Task 32: Cardinality-aware Query Complexity 정책 + +**Files:** +- Create: `modules/graphql/graphql-cost-control/src/main/java/io/backend/skeleton/graphql/cost/GraphQlResolverWeight.java` +- Create: `modules/graphql/graphql-cost-control/src/main/java/io/backend/skeleton/graphql/cost/GraphQlFieldCostDescriptor.java` +- Create: `modules/graphql/graphql-cost-control/src/main/java/io/backend/skeleton/graphql/cost/GraphQlCostCatalog.java` +- Create: `modules/graphql/graphql-cost-control/src/main/java/io/backend/skeleton/graphql/cost/GraphQlComplexityCalculator.java` +- Create: `modules/graphql/graphql-cost-control/src/main/java/io/backend/skeleton/graphql/cost/GraphQlComplexityResult.java` +- Create: `modules/graphql/graphql-cost-control/src/main/java/io/backend/skeleton/graphql/cost/GraphQlComplexityRejectedException.java` +- Test: `modules/graphql/graphql-cost-control/src/test/java/io/backend/skeleton/graphql/cost/GraphQlComplexityCalculatorTest.java` + +**Interfaces:** +- Consumes: 검증된 operation, resolver catalog, connection argument와 client profile의 default·maximum page size. +- Produces: List cardinality와 resolver 유형을 반영한 deterministic complexity score 및 거부 결과. + +**Implementation requirements:** +- Connection에서 `first`·`last`가 없으면 1이 아니라 profile의 default page size를 비용에 사용한다. +- 요청 page size가 maximum을 넘으면 complexity 계산 전에 거부한다. +- JPA indexed lookup, Mongo aggregation, downstream HTTP 등 bounded catalog 기반 resolver weight를 사용한다. +- 알 수 없는 schema coordinate는 비용 0이 아니라 보수적인 default weight를 사용한다. +- 동일 operation과 variables에 대해 계산 결과가 항상 동일해야 한다. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlComplexityCalculatorTest { + @org.junit.jupiter.api.Test + void multipliesConnectionChildrenByEffectivePageSize() { + var catalog = GraphQlCostCatalog.of( + new GraphQlFieldCostDescriptor( + "Query.orders", 2, + GraphQlResolverWeight.BATCHED_RELATION, + true)); + var calculator = new GraphQlComplexityCalculator( + catalog, 20, 100); + + var result = calculator.connectionCost( + "Query.orders", null, null, 5); + + org.assertj.core.api.Assertions.assertThat(result.total()) + .isEqualTo(2L + (20L * 5L)); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-cost-control:test --tests 'io.backend.skeleton.graphql.cost.GraphQlComplexityCalculatorTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public enum GraphQlResolverWeight { + PROPERTY(1), + INDEXED_LOOKUP(2), + BATCHED_RELATION(3), + BOUNDED_AGGREGATION(8), + EXTERNAL_BATCH(10), + EXTERNAL_PER_OBJECT(20); + + private final int weight; + + GraphQlResolverWeight(int weight) { + this.weight = weight; + } + + public int value() { + return weight; + } +} + +public record GraphQlComplexityResult(long total) {} + +public final class GraphQlComplexityCalculator { + private final GraphQlCostCatalog catalog; + private final int defaultPageSize; + private final int maximumPageSize; + + public GraphQlComplexityCalculator( + GraphQlCostCatalog catalog, + int defaultPageSize, + int maximumPageSize) { + this.catalog = catalog; + this.defaultPageSize = defaultPageSize; + this.maximumPageSize = maximumPageSize; + } + + public GraphQlComplexityResult connectionCost( + String coordinate, + Integer first, + Integer last, + long childCost) { + int requested = first != null ? first + : last != null ? last : defaultPageSize; + if (requested > maximumPageSize) { + throw new GraphQlComplexityRejectedException( + "page size exceeds maximum"); + } + long root = catalog.require(coordinate).baseCost(); + return new GraphQlComplexityResult( + Math.addExact(root, + Math.multiplyExact((long) requested, childCost))); + } +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-cost-control:test --tests 'io.backend.skeleton.graphql.cost.GraphQlComplexityCalculatorTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-cost-control/src/main/java/io/backend/skeleton/graphql/cost/GraphQlResolverWeight.java' 'modules/graphql/graphql-cost-control/src/main/java/io/backend/skeleton/graphql/cost/GraphQlFieldCostDescriptor.java' 'modules/graphql/graphql-cost-control/src/main/java/io/backend/skeleton/graphql/cost/GraphQlCostCatalog.java' 'modules/graphql/graphql-cost-control/src/main/java/io/backend/skeleton/graphql/cost/GraphQlComplexityCalculator.java' 'modules/graphql/graphql-cost-control/src/main/java/io/backend/skeleton/graphql/cost/GraphQlComplexityResult.java' 'modules/graphql/graphql-cost-control/src/main/java/io/backend/skeleton/graphql/cost/GraphQlComplexityRejectedException.java' 'modules/graphql/graphql-cost-control/src/test/java/io/backend/skeleton/graphql/cost/GraphQlComplexityCalculatorTest.java' +git commit -m "feat: add graphql complexity calculator" +``` + +### Task 33: Runtime Response Node·Byte Budget + +**Files:** +- Create: `modules/graphql/graphql-cost-control/src/main/java/io/backend/skeleton/graphql/cost/GraphQlRuntimeBudget.java` +- Create: `modules/graphql/graphql-cost-control/src/main/java/io/backend/skeleton/graphql/cost/GraphQlRuntimeBudgetTracker.java` +- Create: `modules/graphql/graphql-cost-control/src/main/java/io/backend/skeleton/graphql/cost/GraphQlResponseNodeCounter.java` +- Create: `modules/graphql/graphql-cost-control/src/main/java/io/backend/skeleton/graphql/cost/GraphQlResponseByteLimiter.java` +- Create: `modules/graphql/graphql-cost-control/src/main/java/io/backend/skeleton/graphql/cost/GraphQlRuntimeBudgetExceededException.java` +- Test: `modules/graphql/graphql-cost-control/src/test/java/io/backend/skeleton/graphql/cost/GraphQlRuntimeBudgetTrackerTest.java` + +**Interfaces:** +- Consumes: 실행 전 complexity 결과, execution context, response serialization pipeline. +- Produces: 실행 중 response node 수와 직렬화 byte 수를 제한하고 cancellation을 전파하는 runtime budget. + +**Implementation requirements:** +- 예상 비용을 통과했어도 실제 result cardinality가 커지면 runtime budget이 실행을 중단한다. +- Node 수와 wire byte 수를 각각 제한한다. +- 제한 초과 후 추가 resolver·publisher 작업에 cancellation을 전달한다. +- 이미 HTTP body가 commit된 뒤의 초과는 connection 종료와 관측 가능한 `PARTIAL_RESPONSE`로 분류한다. +- Error response에 실제 data나 변수 값을 포함하지 않는다. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlRuntimeBudgetTrackerTest { + @org.junit.jupiter.api.Test + void rejectsTheNodeThatCrossesTheBudget() { + var tracker = new GraphQlRuntimeBudgetTracker( + new GraphQlRuntimeBudget(2, 1024)); + tracker.recordNode(); + tracker.recordNode(); + + org.assertj.core.api.Assertions.assertThatThrownBy( + tracker::recordNode) + .isInstanceOf(GraphQlRuntimeBudgetExceededException.class) + .hasMessageContaining("nodes"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-cost-control:test --tests 'io.backend.skeleton.graphql.cost.GraphQlRuntimeBudgetTrackerTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GraphQlRuntimeBudget( + long maxResponseNodes, + long maxResponseBytes) { + public GraphQlRuntimeBudget { + if (maxResponseNodes < 1 || maxResponseBytes < 1) { + throw new IllegalArgumentException( + "runtime budgets must be positive"); + } + } +} + +public final class GraphQlRuntimeBudgetTracker { + private final GraphQlRuntimeBudget budget; + private final java.util.concurrent.atomic.AtomicLong nodes = + new java.util.concurrent.atomic.AtomicLong(); + + public GraphQlRuntimeBudgetTracker(GraphQlRuntimeBudget budget) { + this.budget = java.util.Objects.requireNonNull(budget); + } + + public void recordNode() { + long current = nodes.incrementAndGet(); + if (current > budget.maxResponseNodes()) { + throw new GraphQlRuntimeBudgetExceededException( + "response nodes exceeded"); + } + } +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-cost-control:test --tests 'io.backend.skeleton.graphql.cost.GraphQlRuntimeBudgetTrackerTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-cost-control/src/main/java/io/backend/skeleton/graphql/cost/GraphQlRuntimeBudget.java' 'modules/graphql/graphql-cost-control/src/main/java/io/backend/skeleton/graphql/cost/GraphQlRuntimeBudgetTracker.java' 'modules/graphql/graphql-cost-control/src/main/java/io/backend/skeleton/graphql/cost/GraphQlResponseNodeCounter.java' 'modules/graphql/graphql-cost-control/src/main/java/io/backend/skeleton/graphql/cost/GraphQlResponseByteLimiter.java' 'modules/graphql/graphql-cost-control/src/main/java/io/backend/skeleton/graphql/cost/GraphQlRuntimeBudgetExceededException.java' 'modules/graphql/graphql-cost-control/src/test/java/io/backend/skeleton/graphql/cost/GraphQlRuntimeBudgetTrackerTest.java' +git commit -m "feat: add graphql runtime response budget" +``` + +### Task 34: Production Operation Name 정책 + +**Files:** +- Create: `modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/execution/GraphQlOperationNamePolicy.java` +- Create: `modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/execution/GraphQlOperationSelection.java` +- Create: `modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/execution/GraphQlAnonymousOperationException.java` +- Create: `modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/execution/GraphQlOperationNameInterceptor.java` +- Test: `modules/graphql/graphql-execution/src/test/java/io/backend/skeleton/graphql/execution/GraphQlOperationNamePolicyTest.java` + +**Interfaces:** +- Consumes: 선택된 operation definition, `GraphQlClientPolicy`, environment profile. +- Produces: Production에서 client profile별 named operation 요구와 low-cardinality operation identity. + +**Implementation requirements:** +- Local에서는 단일 anonymous operation을 허용할 수 있지만 Production FIRST_PARTY·PARTNER에는 이름을 요구한다. +- 여러 operation이 있는 document에서 `operationName`이 없으면 항상 request error다. +- Operation name은 bounded catalog와 naming pattern을 검증한다. +- Metric에는 raw query 대신 검증된 operation name만 사용한다. +- Persisted operation은 registry의 operation name과 요청의 name이 일치해야 한다. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlOperationNamePolicyTest { + @org.junit.jupiter.api.Test + void productionFirstPartyRejectsAnonymousOperation() { + var policy = GraphQlOperationNamePolicy.production(); + var client = GraphQlClientProfileName.of("FIRST_PARTY"); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> policy.verify( + client, new GraphQlOperationSelection(null, 1, false))) + .isInstanceOf(GraphQlAnonymousOperationException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-execution:test --tests 'io.backend.skeleton.graphql.execution.GraphQlOperationNamePolicyTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GraphQlOperationSelection( + String operationName, + int operationsInDocument, + boolean persisted) { +} + +public final class GraphQlOperationNamePolicy { + private final boolean production; + + private GraphQlOperationNamePolicy(boolean production) { + this.production = production; + } + + public static GraphQlOperationNamePolicy production() { + return new GraphQlOperationNamePolicy(true); + } + + public void verify( + GraphQlClientProfileName client, + GraphQlOperationSelection selection) { + boolean namedRequired = production + && !"ADMIN".equals(client.value()); + if (selection.operationsInDocument() > 1 + && selection.operationName() == null) { + throw new GraphQlAnonymousOperationException( + "operationName required for multi-operation document"); + } + if (namedRequired && selection.operationName() == null) { + throw new GraphQlAnonymousOperationException( + "named operation required"); + } + } +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-execution:test --tests 'io.backend.skeleton.graphql.execution.GraphQlOperationNamePolicyTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/execution/GraphQlOperationNamePolicy.java' 'modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/execution/GraphQlOperationSelection.java' 'modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/execution/GraphQlAnonymousOperationException.java' 'modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/execution/GraphQlOperationNameInterceptor.java' 'modules/graphql/graphql-execution/src/test/java/io/backend/skeleton/graphql/execution/GraphQlOperationNamePolicyTest.java' +git commit -m "feat: enforce graphql operation names" +``` + +### Task 35: Bounded Preparsed Document Cache + +**Files:** +- Create: `modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/execution/GraphQlPreparsedCacheKey.java` +- Create: `modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/execution/GraphQlPreparsedCachePolicy.java` +- Create: `modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/execution/BoundedPreparsedDocumentProvider.java` +- Create: `modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/execution/GraphQlPreparsedCacheMetrics.java` +- Test: `modules/graphql/graphql-execution/src/test/java/io/backend/skeleton/graphql/execution/BoundedPreparsedDocumentProviderTest.java` + +**Interfaces:** +- Consumes: Document hash, schema contract hash, validation policy version, client schema profile. +- Produces: Parse·validation 결과만 재사용하는 bounded `PreparsedDocumentProvider`. + +**Implementation requirements:** +- 실행 결과를 cache하지 않는다. +- Cache key에 document hash, schema hash, validation policy version, client schema profile을 모두 포함한다. +- Raw query text는 metric label에 사용하지 않는다. +- Maximum entries, maximum weight와 expiry를 설정하며 unbounded map을 사용하지 않는다. +- Schema 또는 validation policy가 바뀌면 이전 entry가 재사용되지 않는다. + +- [ ] **Step 1: Write the failing test** + +```java +class BoundedPreparsedDocumentProviderTest { + @org.junit.jupiter.api.Test + void schemaHashSeparatesOtherwiseIdenticalDocuments() { + var a = new GraphQlPreparsedCacheKey( + "doc", "schema-a", "policy-1", "FIRST_PARTY"); + var b = new GraphQlPreparsedCacheKey( + "doc", "schema-b", "policy-1", "FIRST_PARTY"); + + org.assertj.core.api.Assertions.assertThat(a) + .isNotEqualTo(b); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-execution:test --tests 'io.backend.skeleton.graphql.execution.BoundedPreparsedDocumentProviderTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GraphQlPreparsedCacheKey( + String documentHash, + String schemaContractHash, + String validationPolicyVersion, + String clientSchemaProfile) { + + public GraphQlPreparsedCacheKey { + java.util.Objects.requireNonNull(documentHash); + java.util.Objects.requireNonNull(schemaContractHash); + java.util.Objects.requireNonNull(validationPolicyVersion); + java.util.Objects.requireNonNull(clientSchemaProfile); + } +} + +public record GraphQlPreparsedCachePolicy( + long maximumEntries, + long maximumWeight, + java.time.Duration expireAfterAccess) { +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-execution:test --tests 'io.backend.skeleton.graphql.execution.BoundedPreparsedDocumentProviderTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/execution/GraphQlPreparsedCacheKey.java' 'modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/execution/GraphQlPreparsedCachePolicy.java' 'modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/execution/BoundedPreparsedDocumentProvider.java' 'modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/execution/GraphQlPreparsedCacheMetrics.java' 'modules/graphql/graphql-execution/src/test/java/io/backend/skeleton/graphql/execution/BoundedPreparsedDocumentProviderTest.java' +git commit -m "feat: add bounded graphql preparsed cache" +``` + +### Task 36: Request-scoped DataLoader Policy와 Registry + +**Files:** +- Create: `modules/graphql/graphql-dataloader/src/main/java/io/backend/skeleton/graphql/dataloader/GraphQlBatchPolicy.java` +- Create: `modules/graphql/graphql-dataloader/src/main/java/io/backend/skeleton/graphql/dataloader/GraphQlBatchPolicyRegistry.java` +- Create: `modules/graphql/graphql-dataloader/src/main/java/io/backend/skeleton/graphql/dataloader/GraphQlDataLoaderFactory.java` +- Create: `modules/graphql/graphql-dataloader/src/main/java/io/backend/skeleton/graphql/dataloader/GraphQlDataLoaderRequestRegistry.java` +- Create: `modules/graphql/graphql-dataloader/src/main/java/io/backend/skeleton/graphql/dataloader/GraphQlDataLoaderName.java` +- Test: `modules/graphql/graphql-dataloader/src/test/java/io/backend/skeleton/graphql/dataloader/GraphQlDataLoaderRequestRegistryTest.java` + +**Interfaces:** +- Consumes: `GraphQlRequestContext`, Spring `BatchLoaderRegistry`, bounded loader catalog. +- Produces: Execution마다 새 DataLoader를 생성하고 loader별 batch size·timeout·cache 정책을 적용하는 registry. + +**Implementation requirements:** +- DataLoader instance와 cache는 GraphQL execution 범위를 넘지 않는다. +- Loader 이름은 bounded catalog에 등록돼야 한다. +- Actor·tenant가 다른 execution 사이에 key나 value가 공유되지 않는다. +- Loader별 maximum batch size와 timeout을 startup에서 검증한다. +- Cross-request cache는 이 모듈이 제공하지 않고 Redis/Application Cache에 위임한다. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlDataLoaderRequestRegistryTest { + @org.junit.jupiter.api.Test + void createsDifferentRegistryForEachExecution() { + var factory = GraphQlDataLoaderRequestRegistry::new; + + org.assertj.core.api.Assertions.assertThat(factory.get()) + .isNotSameAs(factory.get()); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-dataloader:test --tests 'io.backend.skeleton.graphql.dataloader.GraphQlDataLoaderRequestRegistryTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GraphQlBatchPolicy( + GraphQlDataLoaderName loaderName, + int maxBatchSize, + java.time.Duration timeout, + boolean requestCacheEnabled) { + + public GraphQlBatchPolicy { + if (maxBatchSize < 1 || timeout.isZero() + || timeout.isNegative()) { + throw new IllegalArgumentException( + "invalid data loader policy"); + } + } +} + +public record GraphQlDataLoaderName(String value) { + public GraphQlDataLoaderName { + if (value == null || !value.matches("[a-z][a-z0-9.-]{2,63}")) { + throw new IllegalArgumentException("invalid loader name"); + } + } +} + +public final class GraphQlDataLoaderRequestRegistry { + private final java.util.Map loaders = + new java.util.HashMap<>(); + + public boolean isEmpty() { + return loaders.isEmpty(); + } +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-dataloader:test --tests 'io.backend.skeleton.graphql.dataloader.GraphQlDataLoaderRequestRegistryTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-dataloader/src/main/java/io/backend/skeleton/graphql/dataloader/GraphQlBatchPolicy.java' 'modules/graphql/graphql-dataloader/src/main/java/io/backend/skeleton/graphql/dataloader/GraphQlBatchPolicyRegistry.java' 'modules/graphql/graphql-dataloader/src/main/java/io/backend/skeleton/graphql/dataloader/GraphQlDataLoaderFactory.java' 'modules/graphql/graphql-dataloader/src/main/java/io/backend/skeleton/graphql/dataloader/GraphQlDataLoaderRequestRegistry.java' 'modules/graphql/graphql-dataloader/src/main/java/io/backend/skeleton/graphql/dataloader/GraphQlDataLoaderName.java' 'modules/graphql/graphql-dataloader/src/test/java/io/backend/skeleton/graphql/dataloader/GraphQlDataLoaderRequestRegistryTest.java' +git commit -m "feat: add request scoped graphql dataloaders" +``` + +### Task 37: Batch Chunking·Context·Deadline 전파 + +**Files:** +- Create: `modules/graphql/graphql-dataloader/src/main/java/io/backend/skeleton/graphql/dataloader/GraphQlBatchContext.java` +- Create: `modules/graphql/graphql-dataloader/src/main/java/io/backend/skeleton/graphql/dataloader/GraphQlBatchChunker.java` +- Create: `modules/graphql/graphql-dataloader/src/main/java/io/backend/skeleton/graphql/dataloader/GraphQlBatchExecutor.java` +- Create: `modules/graphql/graphql-dataloader/src/main/java/io/backend/skeleton/graphql/dataloader/GraphQlBatchTimeoutException.java` +- Create: `modules/graphql/graphql-dataloader/src/main/java/io/backend/skeleton/graphql/dataloader/GraphQlBatchObservation.java` +- Test: `modules/graphql/graphql-dataloader/src/test/java/io/backend/skeleton/graphql/dataloader/GraphQlBatchChunkerTest.java` + +**Interfaces:** +- Consumes: Loader policy, actor·tenant·deadline request context, 저장소 또는 downstream batch function. +- Produces: JPA `IN`, Mongo `$in`, HTTP batch 상한에 맞춘 deterministic chunking과 context-safe batch execution. + +**Implementation requirements:** +- 입력 key 순서를 보존한다. +- Chunk 크기는 loader policy와 downstream hard limit 중 작은 값이다. +- Actor·tenant·deadline을 모든 chunk에 동일하게 전달한다. +- 하나의 chunk timeout이 전체 execution deadline을 초과하지 않는다. +- Batch key 원문을 metric label에 기록하지 않는다. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlBatchChunkerTest { + @org.junit.jupiter.api.Test + void preservesOrderAcrossChunks() { + var chunks = new GraphQlBatchChunker(2) + .chunk(java.util.List.of("a", "b", "c", "d", "e")); + + org.assertj.core.api.Assertions.assertThat(chunks) + .containsExactly( + java.util.List.of("a", "b"), + java.util.List.of("c", "d"), + java.util.List.of("e")); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-dataloader:test --tests 'io.backend.skeleton.graphql.dataloader.GraphQlBatchChunkerTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public final class GraphQlBatchChunker { + private final int maximumChunkSize; + + public GraphQlBatchChunker(int maximumChunkSize) { + if (maximumChunkSize < 1) { + throw new IllegalArgumentException( + "maximumChunkSize must be positive"); + } + this.maximumChunkSize = maximumChunkSize; + } + + public java.util.List> chunk( + java.util.List keys) { + var result = new java.util.ArrayList>(); + for (int start = 0; start < keys.size(); + start += maximumChunkSize) { + int end = Math.min(keys.size(), + start + maximumChunkSize); + result.add(java.util.List.copyOf( + keys.subList(start, end))); + } + return java.util.List.copyOf(result); + } +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-dataloader:test --tests 'io.backend.skeleton.graphql.dataloader.GraphQlBatchChunkerTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-dataloader/src/main/java/io/backend/skeleton/graphql/dataloader/GraphQlBatchContext.java' 'modules/graphql/graphql-dataloader/src/main/java/io/backend/skeleton/graphql/dataloader/GraphQlBatchChunker.java' 'modules/graphql/graphql-dataloader/src/main/java/io/backend/skeleton/graphql/dataloader/GraphQlBatchExecutor.java' 'modules/graphql/graphql-dataloader/src/main/java/io/backend/skeleton/graphql/dataloader/GraphQlBatchTimeoutException.java' 'modules/graphql/graphql-dataloader/src/main/java/io/backend/skeleton/graphql/dataloader/GraphQlBatchObservation.java' 'modules/graphql/graphql-dataloader/src/test/java/io/backend/skeleton/graphql/dataloader/GraphQlBatchChunkerTest.java' +git commit -m "feat: add graphql batch chunk execution" +``` + +### Task 38: Missing Key·Per-key Error Batch Result + +**Files:** +- Create: `modules/graphql/graphql-dataloader/src/main/java/io/backend/skeleton/graphql/dataloader/GraphQlBatchResult.java` +- Create: `modules/graphql/graphql-dataloader/src/main/java/io/backend/skeleton/graphql/dataloader/GraphQlBatchValue.java` +- Create: `modules/graphql/graphql-dataloader/src/main/java/io/backend/skeleton/graphql/dataloader/GraphQlMissingKeyPolicy.java` +- Create: `modules/graphql/graphql-dataloader/src/main/java/io/backend/skeleton/graphql/dataloader/GraphQlBatchErrorPolicy.java` +- Create: `modules/graphql/graphql-dataloader/src/main/java/io/backend/skeleton/graphql/dataloader/GraphQlBatchResultMapper.java` +- Test: `modules/graphql/graphql-dataloader/src/test/java/io/backend/skeleton/graphql/dataloader/GraphQlBatchResultMapperTest.java` + +**Interfaces:** +- Consumes: Ordered or mapped batch loader output, requested key order and stable GraphQL error mapper. +- Produces: Value, missing value와 key별 실패를 구분하면서 요청 순서를 유지하는 batch result. + +**Implementation requirements:** +- 없는 key와 loader 장애를 모두 null로 평탄화하지 않는다. +- Mapped loader는 요청 key마다 결과를 하나 생성한다. +- Ordered loader의 결과 개수가 key 수와 다르면 contract violation이다. +- Key별 오류는 다른 key의 성공 결과를 제거하지 않는다. +- Error message에는 실제 key 원문을 포함하지 않는다. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlBatchResultMapperTest { + @org.junit.jupiter.api.Test + void distinguishesMissingFromFailure() { + var mapper = new GraphQlBatchResultMapper(); + var result = mapper.map( + java.util.List.of("a", "b"), + java.util.Map.of("a", "value")); + + org.assertj.core.api.Assertions.assertThat(result.values()) + .containsEntry("a", GraphQlBatchValue.present("value")) + .containsEntry("b", GraphQlBatchValue.missing()); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-dataloader:test --tests 'io.backend.skeleton.graphql.dataloader.GraphQlBatchResultMapperTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public sealed interface GraphQlBatchValue + permits GraphQlBatchValue.Present, + GraphQlBatchValue.Missing, + GraphQlBatchValue.Failed { + + record Present(V value) implements GraphQlBatchValue {} + record Missing() implements GraphQlBatchValue {} + record Failed(String errorCode) implements GraphQlBatchValue {} + + static GraphQlBatchValue present(V value) { + return new Present<>(value); + } + + static GraphQlBatchValue missing() { + return new Missing<>(); + } +} + +public record GraphQlBatchResult( + java.util.Map> values) { +} + +public final class GraphQlBatchResultMapper { + public GraphQlBatchResult map( + java.util.List keys, + java.util.Map loaded) { + var result = + new java.util.LinkedHashMap>(); + for (K key : keys) { + result.put(key, loaded.containsKey(key) + ? GraphQlBatchValue.present(loaded.get(key)) + : GraphQlBatchValue.missing()); + } + return new GraphQlBatchResult<>( + java.util.Collections.unmodifiableMap(result)); + } +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-dataloader:test --tests 'io.backend.skeleton.graphql.dataloader.GraphQlBatchResultMapperTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-dataloader/src/main/java/io/backend/skeleton/graphql/dataloader/GraphQlBatchResult.java' 'modules/graphql/graphql-dataloader/src/main/java/io/backend/skeleton/graphql/dataloader/GraphQlBatchValue.java' 'modules/graphql/graphql-dataloader/src/main/java/io/backend/skeleton/graphql/dataloader/GraphQlMissingKeyPolicy.java' 'modules/graphql/graphql-dataloader/src/main/java/io/backend/skeleton/graphql/dataloader/GraphQlBatchErrorPolicy.java' 'modules/graphql/graphql-dataloader/src/main/java/io/backend/skeleton/graphql/dataloader/GraphQlBatchResultMapper.java' 'modules/graphql/graphql-dataloader/src/test/java/io/backend/skeleton/graphql/dataloader/GraphQlBatchResultMapperTest.java' +git commit -m "feat: add graphql per key batch results" +``` + +### Task 39: Registered Fetch Profile Catalog + +**Files:** +- Create: `modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/fetch/GraphQlFetchProfileName.java` +- Create: `modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/fetch/GraphQlFetchProfile.java` +- Create: `modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/fetch/GraphQlFetchProfileRegistry.java` +- Create: `modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/fetch/GraphQlSelectionCoordinate.java` +- Create: `modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/fetch/GraphQlFetchProfileValidationException.java` +- Test: `modules/graphql/graphql-execution/src/test/java/io/backend/skeleton/graphql/fetch/GraphQlFetchProfileRegistryTest.java` + +**Interfaces:** +- Consumes: Schema coordinates, 도메인 모듈이 등록한 bounded read-model profile와 repository query name. +- Produces: GraphQL selection을 JPA·Mongo 구현 세부와 분리하는 유한 Fetch Profile catalog. + +**Implementation requirements:** +- Profile은 schema type과 bounded field set을 명시한다. +- JPA EntityGraph, JPQL, Mongo projection 같은 저장소 구현 타입을 public API에 노출하지 않는다. +- 동일 coordinate·profile 이름의 중복 등록은 startup 실패다. +- Default profile을 type마다 하나만 허용한다. +- Profile에 필드 권한 우회 또는 비공개 schema coordinate가 포함되면 등록을 거부한다. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlFetchProfileRegistryTest { + @org.junit.jupiter.api.Test + void duplicateProfileNameFailsAtRegistration() { + var registry = new GraphQlFetchProfileRegistry(); + var profile = new GraphQlFetchProfile( + new GraphQlFetchProfileName("Order.BASIC"), + "Order", + java.util.Set.of("id", "status"), + "order-basic", + true); + registry.register(profile); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> registry.register(profile)) + .isInstanceOf( + GraphQlFetchProfileValidationException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-execution:test --tests 'io.backend.skeleton.graphql.fetch.GraphQlFetchProfileRegistryTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GraphQlFetchProfileName(String value) { + public GraphQlFetchProfileName { + if (value == null + || !value.matches("[A-Z][A-Za-z0-9]+\\.[A-Z_]+")) { + throw new IllegalArgumentException( + "invalid fetch profile name"); + } + } +} + +public record GraphQlFetchProfile( + GraphQlFetchProfileName name, + String schemaType, + java.util.Set fields, + String applicationQueryProfile, + boolean defaultProfile) { + public GraphQlFetchProfile { + fields = java.util.Set.copyOf(fields); + } +} + +public final class GraphQlFetchProfileRegistry { + private final java.util.Map profiles = new java.util.LinkedHashMap<>(); + + public void register(GraphQlFetchProfile profile) { + if (profiles.putIfAbsent(profile.name(), profile) != null) { + throw new GraphQlFetchProfileValidationException( + "duplicate fetch profile " + profile.name().value()); + } + } + + public GraphQlFetchProfile require( + GraphQlFetchProfileName name) { + var value = profiles.get(name); + if (value == null) { + throw new GraphQlFetchProfileValidationException( + "unknown fetch profile " + name.value()); + } + return value; + } +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-execution:test --tests 'io.backend.skeleton.graphql.fetch.GraphQlFetchProfileRegistryTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/fetch/GraphQlFetchProfileName.java' 'modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/fetch/GraphQlFetchProfile.java' 'modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/fetch/GraphQlFetchProfileRegistry.java' 'modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/fetch/GraphQlSelectionCoordinate.java' 'modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/fetch/GraphQlFetchProfileValidationException.java' 'modules/graphql/graphql-execution/src/test/java/io/backend/skeleton/graphql/fetch/GraphQlFetchProfileRegistryTest.java' +git commit -m "feat: add graphql fetch profile catalog" +``` + +### Task 40: Selection Set → Fetch Profile Classifier + +**Files:** +- Create: `modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/fetch/GraphQlSelectionSetView.java` +- Create: `modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/fetch/GraphQlSelectionSignature.java` +- Create: `modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/fetch/GraphQlFetchProfileRule.java` +- Create: `modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/fetch/GraphQlFetchProfileClassifier.java` +- Create: `modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/fetch/GraphQlUnmappedSelectionException.java` +- Test: `modules/graphql/graphql-execution/src/test/java/io/backend/skeleton/graphql/fetch/GraphQlFetchProfileClassifierTest.java` + +**Interfaces:** +- Consumes: Validated `DataFetchingFieldSelectionSet`, Fetch Profile catalog and coordinate-specific mapping rules. +- Produces: 자유형 SQL·Mongo projection 생성 없이 하나의 등록 profile을 선택하는 deterministic classifier. + +**Implementation requirements:** +- Selection의 field path는 정규화된 schema coordinate로만 비교한다. +- Alias는 실제 field coordinate로 환원한다. +- Fragment·inline fragment를 펼친 뒤 동일 의미 selection은 같은 signature를 생성한다. +- 어떤 profile에도 안전하게 매핑되지 않는 selection은 full entity 자동 조회가 아니라 명시적 오류 또는 승인된 fallback profile을 사용한다. +- 권한상 보이지 않는 field는 profile 선택 전에 제거하는 것이 아니라 authorization에서 거부한다. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlFetchProfileClassifierTest { + @org.junit.jupiter.api.Test + void choosesSmallestProfileCoveringTheSelection() { + var basic = new GraphQlFetchProfile( + new GraphQlFetchProfileName("Order.BASIC"), "Order", + java.util.Set.of("id", "status"), "order-basic", true); + var full = new GraphQlFetchProfile( + new GraphQlFetchProfileName("Order.FULL_DETAIL"), "Order", + java.util.Set.of("id", "status", "items", "customer"), + "order-full", false); + var classifier = + new GraphQlFetchProfileClassifier(java.util.List.of(full, basic)); + + org.assertj.core.api.Assertions.assertThat( + classifier.classify("Order", + java.util.Set.of("id", "status")).name()) + .isEqualTo(new GraphQlFetchProfileName("Order.BASIC")); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-execution:test --tests 'io.backend.skeleton.graphql.fetch.GraphQlFetchProfileClassifierTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public final class GraphQlFetchProfileClassifier { + private final java.util.List profiles; + + public GraphQlFetchProfileClassifier( + java.util.List profiles) { + this.profiles = profiles.stream() + .sorted(java.util.Comparator.comparingInt( + profile -> profile.fields().size())) + .toList(); + } + + public GraphQlFetchProfile classify( + String schemaType, + java.util.Set selectedFields) { + return profiles.stream() + .filter(profile -> profile.schemaType().equals(schemaType)) + .filter(profile -> + profile.fields().containsAll(selectedFields)) + .findFirst() + .orElseThrow(() -> + new GraphQlUnmappedSelectionException( + schemaType, selectedFields.size())); + } +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-execution:test --tests 'io.backend.skeleton.graphql.fetch.GraphQlFetchProfileClassifierTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/fetch/GraphQlSelectionSetView.java' 'modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/fetch/GraphQlSelectionSignature.java' 'modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/fetch/GraphQlFetchProfileRule.java' 'modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/fetch/GraphQlFetchProfileClassifier.java' 'modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/fetch/GraphQlUnmappedSelectionException.java' 'modules/graphql/graphql-execution/src/test/java/io/backend/skeleton/graphql/fetch/GraphQlFetchProfileClassifierTest.java' +git commit -m "feat: classify graphql selections into fetch profiles" +``` + +### Task 41: Versioned HMAC Cursor Codec + +**Files:** +- Create: `modules/graphql/graphql-pagination/src/main/java/io/backend/skeleton/graphql/pagination/GraphQlCursorVersion.java` +- Create: `modules/graphql/graphql-pagination/src/main/java/io/backend/skeleton/graphql/pagination/GraphQlCursorPayload.java` +- Create: `modules/graphql/graphql-pagination/src/main/java/io/backend/skeleton/graphql/pagination/GraphQlCursorKeyset.java` +- Create: `modules/graphql/graphql-pagination/src/main/java/io/backend/skeleton/graphql/pagination/GraphQlCursorCodec.java` +- Create: `modules/graphql/graphql-pagination/src/main/java/io/backend/skeleton/graphql/pagination/HmacGraphQlCursorCodec.java` +- Create: `modules/graphql/graphql-pagination/src/main/java/io/backend/skeleton/graphql/pagination/GraphQlCursorKeyRing.java` +- Create: `modules/graphql/graphql-pagination/src/main/java/io/backend/skeleton/graphql/pagination/GraphQlCursorException.java` +- Test: `modules/graphql/graphql-pagination/src/test/java/io/backend/skeleton/graphql/pagination/HmacGraphQlCursorCodecTest.java` + +**Interfaces:** +- Consumes: Query profile, direction, keyset, filter fingerprint와 rotation 가능한 HMAC key ring. +- Produces: Client에게 opaque하고 변조·profile 재사용을 탐지하는 signed versioned cursor. + +**Implementation requirements:** +- Base64 encoding만으로 무결성을 주장하지 않는다. +- Cursor에 version, query profile, direction, complete sort keyset, filter fingerprint와 key ID를 포함한다. +- Unknown version, unknown key ID, invalid MAC, 다른 filter/profile 재사용을 거부한다. +- HMAC 비교는 constant-time API를 사용한다. +- Cursor payload에 credential, raw tenant ID 또는 불필요한 PII를 넣지 않는다. + +- [ ] **Step 1: Write the failing test** + +```java +class HmacGraphQlCursorCodecTest { + @org.junit.jupiter.api.Test + void rejectsCursorWhenFilterFingerprintChanges() { + var codec = HmacGraphQlCursorCodec.testCodec( + "cursor-key-1", "secret-secret-secret".getBytes()); + var payload = GraphQlCursorPayload.of( + "orders-by-created", "FORWARD", + java.util.Map.of("createdAt", "2026-08-12T00:00:00Z", + "id", "01J0"), + "filter-a"); + var encoded = codec.encode(payload); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> codec.decode( + encoded, "orders-by-created", "filter-b")) + .isInstanceOf(GraphQlCursorException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-pagination:test --tests 'io.backend.skeleton.graphql.pagination.HmacGraphQlCursorCodecTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GraphQlCursorPayload( + int version, + String queryProfile, + String direction, + java.util.Map keyset, + String filterFingerprint, + String keyId) { + + public static GraphQlCursorPayload of( + String queryProfile, + String direction, + java.util.Map keyset, + String filterFingerprint) { + return new GraphQlCursorPayload( + 1, queryProfile, direction, + java.util.Map.copyOf(keyset), + filterFingerprint, "cursor-key-1"); + } +} + +public interface GraphQlCursorCodec { + String encode(GraphQlCursorPayload payload); + + GraphQlCursorPayload decode( + String cursor, + String expectedQueryProfile, + String expectedFilterFingerprint); +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-pagination:test --tests 'io.backend.skeleton.graphql.pagination.HmacGraphQlCursorCodecTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-pagination/src/main/java/io/backend/skeleton/graphql/pagination/GraphQlCursorVersion.java' 'modules/graphql/graphql-pagination/src/main/java/io/backend/skeleton/graphql/pagination/GraphQlCursorPayload.java' 'modules/graphql/graphql-pagination/src/main/java/io/backend/skeleton/graphql/pagination/GraphQlCursorKeyset.java' 'modules/graphql/graphql-pagination/src/main/java/io/backend/skeleton/graphql/pagination/GraphQlCursorCodec.java' 'modules/graphql/graphql-pagination/src/main/java/io/backend/skeleton/graphql/pagination/HmacGraphQlCursorCodec.java' 'modules/graphql/graphql-pagination/src/main/java/io/backend/skeleton/graphql/pagination/GraphQlCursorKeyRing.java' 'modules/graphql/graphql-pagination/src/main/java/io/backend/skeleton/graphql/pagination/GraphQlCursorException.java' 'modules/graphql/graphql-pagination/src/test/java/io/backend/skeleton/graphql/pagination/HmacGraphQlCursorCodecTest.java' +git commit -m "feat: add signed graphql cursor codec" +``` + +### Task 42: Connection·Edge·PageInfo와 Storage Keyset Adapter + +**Files:** +- Create: `modules/graphql/graphql-pagination/src/main/java/io/backend/skeleton/graphql/pagination/GraphQlConnection.java` +- Create: `modules/graphql/graphql-pagination/src/main/java/io/backend/skeleton/graphql/pagination/GraphQlEdge.java` +- Create: `modules/graphql/graphql-pagination/src/main/java/io/backend/skeleton/graphql/pagination/GraphQlPageInfo.java` +- Create: `modules/graphql/graphql-pagination/src/main/java/io/backend/skeleton/graphql/pagination/GraphQlConnectionRequest.java` +- Create: `modules/graphql/graphql-pagination/src/main/java/io/backend/skeleton/graphql/pagination/GraphQlConnectionPolicy.java` +- Create: `modules/graphql/graphql-pagination/src/main/java/io/backend/skeleton/graphql/pagination/GraphQlConnectionAssembler.java` +- Create: `modules/graphql/graphql-pagination/src/main/java/io/backend/skeleton/graphql/pagination/GraphQlKeysetWindow.java` +- Test: `modules/graphql/graphql-pagination/src/test/java/io/backend/skeleton/graphql/pagination/GraphQlConnectionAssemblerTest.java` + +**Interfaces:** +- Consumes: 검증된 `first/after/last/before`, signed cursor codec와 JPA·Mongo·upstream이 반환한 keyset window. +- Produces: 저장소에 독립적인 Connection·Edge·PageInfo wire model과 cursor assembly. + +**Implementation requirements:** +- Forward와 backward 요청에서 한 방향의 argument 조합만 허용한다. +- Default page size와 maximum page size를 client profile에서 적용한다. +- Storage query는 요청 크기보다 한 건 더 읽어 `hasNextPage` 또는 `hasPreviousPage`를 계산한다. +- `totalCount`를 모든 connection에 강제하지 않는다. +- Tie-breaker 없는 keyset profile은 startup 또는 request 전에 거부한다. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlConnectionAssemblerTest { + @org.junit.jupiter.api.Test + void extraRowBecomesHasNextPageAndIsNotReturned() { + var window = new GraphQlKeysetWindow<>( + java.util.List.of("a", "b", "c"), 2, false); + var assembler = GraphQlConnectionAssembler.forTests(); + + var connection = assembler.forward( + window, value -> java.util.Map.of("id", value)); + + org.assertj.core.api.Assertions.assertThat(connection.edges()) + .extracting(GraphQlEdge::node) + .containsExactly("a", "b"); + org.assertj.core.api.Assertions.assertThat( + connection.pageInfo().hasNextPage()).isTrue(); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-pagination:test --tests 'io.backend.skeleton.graphql.pagination.GraphQlConnectionAssemblerTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GraphQlEdge(T node, String cursor) {} + +public record GraphQlPageInfo( + boolean hasNextPage, + boolean hasPreviousPage, + String startCursor, + String endCursor) { +} + +public record GraphQlConnection( + java.util.List> edges, + GraphQlPageInfo pageInfo) { + public GraphQlConnection { + edges = java.util.List.copyOf(edges); + } +} + +public record GraphQlKeysetWindow( + java.util.List values, + int requestedSize, + boolean hasPreviousPage) { +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-pagination:test --tests 'io.backend.skeleton.graphql.pagination.GraphQlConnectionAssemblerTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-pagination/src/main/java/io/backend/skeleton/graphql/pagination/GraphQlConnection.java' 'modules/graphql/graphql-pagination/src/main/java/io/backend/skeleton/graphql/pagination/GraphQlEdge.java' 'modules/graphql/graphql-pagination/src/main/java/io/backend/skeleton/graphql/pagination/GraphQlPageInfo.java' 'modules/graphql/graphql-pagination/src/main/java/io/backend/skeleton/graphql/pagination/GraphQlConnectionRequest.java' 'modules/graphql/graphql-pagination/src/main/java/io/backend/skeleton/graphql/pagination/GraphQlConnectionPolicy.java' 'modules/graphql/graphql-pagination/src/main/java/io/backend/skeleton/graphql/pagination/GraphQlConnectionAssembler.java' 'modules/graphql/graphql-pagination/src/main/java/io/backend/skeleton/graphql/pagination/GraphQlKeysetWindow.java' 'modules/graphql/graphql-pagination/src/test/java/io/backend/skeleton/graphql/pagination/GraphQlConnectionAssemblerTest.java' +git commit -m "feat: add graphql connection pagination" +``` + +### Task 43: Mutation Idempotency Context와 Fingerprint + +**Files:** +- Create: `modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/mutation/GraphQlMutationCoordinate.java` +- Create: `modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/mutation/GraphQlIdempotencyKey.java` +- Create: `modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/mutation/GraphQlMutationFingerprint.java` +- Create: `modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/mutation/GraphQlMutationIdempotencyContext.java` +- Create: `modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/mutation/GraphQlMutationIdempotencyInterceptor.java` +- Create: `modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/mutation/GraphQlIdempotencyConflictException.java` +- Test: `modules/graphql/graphql-execution/src/test/java/io/backend/skeleton/graphql/mutation/GraphQlMutationIdempotencyContextTest.java` + +**Interfaces:** +- Consumes: HTTP `Idempotency-Key` 또는 typed mutation input key, actor/client identity, mutation coordinate, normalized input. +- Produces: Application Use Case의 idempotency capability에 전달할 bounded mutation identity와 conflict 판정. + +**Implementation requirements:** +- Idempotency 범위는 GraphQL transport 전체가 아니라 side-effecting mutation coordinate와 actor/client identity다. +- 같은 key와 같은 fingerprint는 기존 결과를 조회할 수 있게 한다. +- 같은 key와 다른 normalized input fingerprint는 conflict다. +- Platform은 DB replay를 직접 구현하지 않고 Application Idempotency Port로 context를 전달한다. +- Raw variables와 idempotency key를 metric·일반 로그에 기록하지 않는다. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlMutationIdempotencyContextTest { + @org.junit.jupiter.api.Test + void sameKeyWithDifferentFingerprintIsConflict() { + var key = new GraphQlIdempotencyKey("request-1"); + var first = GraphQlMutationIdempotencyContext.of( + "actor-fingerprint", + new GraphQlMutationCoordinate("Mutation.createOrder"), + key, + new GraphQlMutationFingerprint("sha256:a")); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> first.assertCompatible( + new GraphQlMutationFingerprint("sha256:b"))) + .isInstanceOf(GraphQlIdempotencyConflictException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-execution:test --tests 'io.backend.skeleton.graphql.mutation.GraphQlMutationIdempotencyContextTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GraphQlMutationCoordinate(String value) {} + +public record GraphQlIdempotencyKey(String value) { + public GraphQlIdempotencyKey { + if (value == null || value.length() < 8 + || value.length() > 128) { + throw new IllegalArgumentException( + "invalid idempotency key"); + } + } +} + +public record GraphQlMutationFingerprint(String value) {} + +public record GraphQlMutationIdempotencyContext( + String actorFingerprint, + GraphQlMutationCoordinate coordinate, + GraphQlIdempotencyKey key, + GraphQlMutationFingerprint fingerprint) { + + public static GraphQlMutationIdempotencyContext of( + String actorFingerprint, + GraphQlMutationCoordinate coordinate, + GraphQlIdempotencyKey key, + GraphQlMutationFingerprint fingerprint) { + return new GraphQlMutationIdempotencyContext( + actorFingerprint, coordinate, key, fingerprint); + } + + public void assertCompatible( + GraphQlMutationFingerprint candidate) { + if (!fingerprint.equals(candidate)) { + throw new GraphQlIdempotencyConflictException( + "idempotency fingerprint conflict"); + } + } +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-execution:test --tests 'io.backend.skeleton.graphql.mutation.GraphQlMutationIdempotencyContextTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/mutation/GraphQlMutationCoordinate.java' 'modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/mutation/GraphQlIdempotencyKey.java' 'modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/mutation/GraphQlMutationFingerprint.java' 'modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/mutation/GraphQlMutationIdempotencyContext.java' 'modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/mutation/GraphQlMutationIdempotencyInterceptor.java' 'modules/graphql/graphql-execution/src/main/java/io/backend/skeleton/graphql/mutation/GraphQlIdempotencyConflictException.java' 'modules/graphql/graphql-execution/src/test/java/io/backend/skeleton/graphql/mutation/GraphQlMutationIdempotencyContextTest.java' +git commit -m "feat: add graphql mutation idempotency context" +``` + +### Task 44: Optimistic Version과 Typed Business Result 계약 + +**Files:** +- Create: `modules/graphql/graphql-controller/src/main/java/io/backend/skeleton/graphql/mutation/GraphQlExpectedVersion.java` +- Create: `modules/graphql/graphql-controller/src/main/java/io/backend/skeleton/graphql/mutation/GraphQlMutationPayload.java` +- Create: `modules/graphql/graphql-controller/src/main/java/io/backend/skeleton/graphql/mutation/GraphQlBusinessResult.java` +- Create: `modules/graphql/graphql-controller/src/main/java/io/backend/skeleton/graphql/mutation/GraphQlMutationResultMapper.java` +- Create: `modules/graphql/graphql-controller/src/main/java/io/backend/skeleton/graphql/mutation/GraphQlBatchMutationItemResult.java` +- Create: `modules/graphql/graphql-controller/src/main/java/io/backend/skeleton/graphql/mutation/GraphQlMutationContractValidator.java` +- Test: `modules/graphql/graphql-controller/src/test/java/io/backend/skeleton/graphql/mutation/GraphQlMutationResultMapperTest.java` + +**Interfaces:** +- Consumes: Application Use Case의 success·conflict·validation result와 persistence 모듈의 optimistic conflict. +- Produces: 예상 가능한 업무 결과는 typed payload/union으로, 예상 밖 장애는 GraphQL error로 분리하는 mutation contract. + +**Implementation requirements:** +- Mutation root field 하나가 Application Use Case 하나를 호출한다. +- 여러 root mutation field를 하나의 DB transaction으로 묶지 않는다. +- Atomic해야 하는 복합 업무는 하나의 mutation/use case로 모델링한다. +- Batch mutation은 item별 success·failure를 보존하고 top-level error 하나로 결과를 잃지 않는다. +- Expected version은 Application command로 전달하며 GraphQL 계층이 persistence retry를 수행하지 않는다. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlMutationResultMapperTest { + @org.junit.jupiter.api.Test + void businessConflictBecomesTypedResultNotInternalError() { + var mapper = new GraphQlMutationResultMapper(); + + var result = mapper.map( + GraphQlBusinessResult.conflict("ORDER_VERSION_CONFLICT")); + + org.assertj.core.api.Assertions.assertThat(result.status()) + .isEqualTo("CONFLICT"); + org.assertj.core.api.Assertions.assertThat(result.code()) + .isEqualTo("ORDER_VERSION_CONFLICT"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-controller:test --tests 'io.backend.skeleton.graphql.mutation.GraphQlMutationResultMapperTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GraphQlExpectedVersion(long value) { + public GraphQlExpectedVersion { + if (value < 0) { + throw new IllegalArgumentException( + "expected version cannot be negative"); + } + } +} + +public record GraphQlMutationPayload( + String status, + String code, + T value) { +} + +public sealed interface GraphQlBusinessResult + permits GraphQlBusinessResult.Success, + GraphQlBusinessResult.Conflict, + GraphQlBusinessResult.Invalid { + + record Success(T value) implements GraphQlBusinessResult {} + record Conflict(String code) + implements GraphQlBusinessResult {} + record Invalid(String code) + implements GraphQlBusinessResult {} + + static GraphQlBusinessResult conflict(String code) { + return new Conflict<>(code); + } +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-controller:test --tests 'io.backend.skeleton.graphql.mutation.GraphQlMutationResultMapperTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-controller/src/main/java/io/backend/skeleton/graphql/mutation/GraphQlExpectedVersion.java' 'modules/graphql/graphql-controller/src/main/java/io/backend/skeleton/graphql/mutation/GraphQlMutationPayload.java' 'modules/graphql/graphql-controller/src/main/java/io/backend/skeleton/graphql/mutation/GraphQlBusinessResult.java' 'modules/graphql/graphql-controller/src/main/java/io/backend/skeleton/graphql/mutation/GraphQlMutationResultMapper.java' 'modules/graphql/graphql-controller/src/main/java/io/backend/skeleton/graphql/mutation/GraphQlBatchMutationItemResult.java' 'modules/graphql/graphql-controller/src/main/java/io/backend/skeleton/graphql/mutation/GraphQlMutationContractValidator.java' 'modules/graphql/graphql-controller/src/test/java/io/backend/skeleton/graphql/mutation/GraphQlMutationResultMapperTest.java' +git commit -m "feat: add graphql typed mutation results" +``` + +### Task 45: Request·Resolver·DataLoader Observability 계약 + +**Files:** +- Create: `modules/graphql/graphql-observability/src/main/java/io/backend/skeleton/graphql/observation/GraphQlObservationNames.java` +- Create: `modules/graphql/graphql-observability/src/main/java/io/backend/skeleton/graphql/observation/GraphQlRequestObservationConvention.java` +- Create: `modules/graphql/graphql-observability/src/main/java/io/backend/skeleton/graphql/observation/GraphQlResolverObservationConvention.java` +- Create: `modules/graphql/graphql-observability/src/main/java/io/backend/skeleton/graphql/observation/GraphQlDataLoaderObservationConvention.java` +- Create: `modules/graphql/graphql-observability/src/main/java/io/backend/skeleton/graphql/observation/GraphQlMetricCardinalityPolicy.java` +- Create: `modules/graphql/graphql-observability/src/main/java/io/backend/skeleton/graphql/observation/GraphQlSensitiveAttributeFilter.java` +- Create: `modules/graphql/graphql-observability/src/main/java/io/backend/skeleton/graphql/observation/GraphQlProfilerAccessPolicy.java` +- Test: `modules/graphql/graphql-observability/src/test/java/io/backend/skeleton/graphql/observation/GraphQlMetricCardinalityPolicyTest.java` + +**Interfaces:** +- Consumes: Spring for GraphQL Micrometer observations, operation/resolver/loader bounded catalogs와 execution outcome. +- Produces: 논리 request, resolver와 DataLoader의 low-cardinality metric·trace naming 및 민감 attribute 필터. + +**Implementation requirements:** +- `graphql.request`, `graphql.datafetcher`, `graphql.dataloader` 기본 observation을 재사용한다. +- 허용 tag는 등록된 operationName, operationType, clientProfile, schemaCoordinate, loaderName, outcome, error category다. +- Raw query, variables, cursor, object ID, user/tenant raw ID, token을 tag와 일반 trace attribute에 넣지 않는다. +- Anonymous operation은 Production 정책에서 이미 차단되며 fallback tag는 bounded `anonymous`만 사용한다. +- GraphQL Java Profiler는 Local/Dev 또는 G4 diagnostic에서만 활성화한다. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlMetricCardinalityPolicyTest { + @org.junit.jupiter.api.Test + void rejectsVariablesAndRawQueryAsMetricTags() { + var policy = GraphQlMetricCardinalityPolicy.standard(); + + org.assertj.core.api.Assertions.assertThat( + policy.isAllowed("graphql.document")).isFalse(); + org.assertj.core.api.Assertions.assertThat( + policy.isAllowed("graphql.variables")).isFalse(); + org.assertj.core.api.Assertions.assertThat( + policy.isAllowed("graphql.operation.name")).isTrue(); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-observability:test --tests 'io.backend.skeleton.graphql.observation.GraphQlMetricCardinalityPolicyTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public final class GraphQlMetricCardinalityPolicy { + private static final java.util.Set ALLOWED = + java.util.Set.of( + "graphql.operation.name", + "graphql.operation.type", + "graphql.client.profile", + "graphql.schema.coordinate", + "graphql.dataloader.name", + "graphql.outcome", + "error.type"); + + public static GraphQlMetricCardinalityPolicy standard() { + return new GraphQlMetricCardinalityPolicy(); + } + + public boolean isAllowed(String attribute) { + return ALLOWED.contains(attribute); + } +} + +public final class GraphQlObservationNames { + public static final String REQUEST = "graphql.request"; + public static final String RESOLVER = "graphql.datafetcher"; + public static final String DATA_LOADER = "graphql.dataloader"; + + private GraphQlObservationNames() {} +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-observability:test --tests 'io.backend.skeleton.graphql.observation.GraphQlMetricCardinalityPolicyTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-observability/src/main/java/io/backend/skeleton/graphql/observation/GraphQlObservationNames.java' 'modules/graphql/graphql-observability/src/main/java/io/backend/skeleton/graphql/observation/GraphQlRequestObservationConvention.java' 'modules/graphql/graphql-observability/src/main/java/io/backend/skeleton/graphql/observation/GraphQlResolverObservationConvention.java' 'modules/graphql/graphql-observability/src/main/java/io/backend/skeleton/graphql/observation/GraphQlDataLoaderObservationConvention.java' 'modules/graphql/graphql-observability/src/main/java/io/backend/skeleton/graphql/observation/GraphQlMetricCardinalityPolicy.java' 'modules/graphql/graphql-observability/src/main/java/io/backend/skeleton/graphql/observation/GraphQlSensitiveAttributeFilter.java' 'modules/graphql/graphql-observability/src/main/java/io/backend/skeleton/graphql/observation/GraphQlProfilerAccessPolicy.java' 'modules/graphql/graphql-observability/src/test/java/io/backend/skeleton/graphql/observation/GraphQlMetricCardinalityPolicyTest.java' +git commit -m "feat: add graphql observability policy" +``` + +### Task 46: Spring Boot Starter와 Startup Validation + +**Files:** +- Create: `modules/graphql/graphql-spring-boot-starter/src/main/java/io/backend/skeleton/graphql/autoconfigure/GraphQlPlatformProperties.java` +- Create: `modules/graphql/graphql-spring-boot-starter/src/main/java/io/backend/skeleton/graphql/autoconfigure/GraphQlPlatformAutoConfiguration.java` +- Create: `modules/graphql/graphql-spring-boot-starter/src/main/java/io/backend/skeleton/graphql/autoconfigure/GraphQlPlatformStartupValidator.java` +- Create: `modules/graphql/graphql-spring-boot-starter/src/main/java/io/backend/skeleton/graphql/autoconfigure/GraphQlPlatformActuatorEndpoint.java` +- Create: `modules/graphql/graphql-spring-boot-starter/src/main/java/io/backend/skeleton/graphql/autoconfigure/GraphQlPlatformConfigurationReport.java` +- Create: `modules/graphql/graphql-spring-boot-starter/src/main/java/io/backend/skeleton/graphql/autoconfigure/GraphQlPlatformEnvironment.java` +- Test: `modules/graphql/graphql-spring-boot-starter/src/test/java/io/backend/skeleton/graphql/autoconfigure/GraphQlPlatformStartupValidatorTest.java` + +**Interfaces:** +- Consumes: Schema, policy, resolver, DataLoader, fetch profile, cursor key, transport와 security manifest. +- Produces: Stable 모듈만 조립하고 위험하거나 모순된 설정을 시작 단계에서 차단하는 Boot starter. + +**Implementation requirements:** +- Production에서 GraphiQL 활성, 무제한 request/complexity/page size, cursor HMAC key 누락을 거부한다. +- GraphQL multipart upload, HTTP batch, request-wide DB transaction과 raw repository auto-exposure 설정이 있으면 거부한다. +- WebFlux profile에서 BLOCKING resolver가 executor bridge 없이 등록되면 거부한다. +- Schema mapping, scalar, DataLoader, Fetch Profile, cost catalog와 operation catalog drift를 startup에서 검증한다. +- Actuator endpoint는 hash·지원 capability·bounded 상태만 노출하고 SDL, persisted document, secret을 반환하지 않는다. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlPlatformStartupValidatorTest { + @org.junit.jupiter.api.Test + void productionRejectsGraphiqlAndMissingCursorKey() { + var properties = GraphQlPlatformProperties.productionDefaults() + .withGraphiqlEnabled(true) + .withCursorKeyIds(java.util.Set.of()); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> new GraphQlPlatformStartupValidator() + .validate(properties)) + .isInstanceOf( + GraphQlPlatformConfigurationException.class); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-spring-boot-starter:test --tests 'io.backend.skeleton.graphql.autoconfigure.GraphQlPlatformStartupValidatorTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +@org.springframework.boot.context.properties.ConfigurationProperties( + "backend.graphql") +public record GraphQlPlatformProperties( + boolean production, + boolean graphiqlEnabled, + int maximumPageSize, + long maximumComplexity, + java.util.Set cursorKeyIds) { + + public static GraphQlPlatformProperties productionDefaults() { + return new GraphQlPlatformProperties( + true, false, 100, 10_000, + java.util.Set.of("cursor-key-1")); + } + + public GraphQlPlatformProperties withGraphiqlEnabled( + boolean enabled) { + return new GraphQlPlatformProperties( + production, enabled, maximumPageSize, + maximumComplexity, cursorKeyIds); + } + + public GraphQlPlatformProperties withCursorKeyIds( + java.util.Set keyIds) { + return new GraphQlPlatformProperties( + production, graphiqlEnabled, maximumPageSize, + maximumComplexity, java.util.Set.copyOf(keyIds)); + } +} + +public final class GraphQlPlatformStartupValidator { + public void validate(GraphQlPlatformProperties properties) { + if (properties.production() + && (properties.graphiqlEnabled() + || properties.cursorKeyIds().isEmpty())) { + throw new GraphQlPlatformConfigurationException( + "unsafe graphql production configuration"); + } + } +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-spring-boot-starter:test --tests 'io.backend.skeleton.graphql.autoconfigure.GraphQlPlatformStartupValidatorTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-spring-boot-starter/src/main/java/io/backend/skeleton/graphql/autoconfigure/GraphQlPlatformProperties.java' 'modules/graphql/graphql-spring-boot-starter/src/main/java/io/backend/skeleton/graphql/autoconfigure/GraphQlPlatformAutoConfiguration.java' 'modules/graphql/graphql-spring-boot-starter/src/main/java/io/backend/skeleton/graphql/autoconfigure/GraphQlPlatformStartupValidator.java' 'modules/graphql/graphql-spring-boot-starter/src/main/java/io/backend/skeleton/graphql/autoconfigure/GraphQlPlatformActuatorEndpoint.java' 'modules/graphql/graphql-spring-boot-starter/src/main/java/io/backend/skeleton/graphql/autoconfigure/GraphQlPlatformConfigurationReport.java' 'modules/graphql/graphql-spring-boot-starter/src/main/java/io/backend/skeleton/graphql/autoconfigure/GraphQlPlatformEnvironment.java' 'modules/graphql/graphql-spring-boot-starter/src/test/java/io/backend/skeleton/graphql/autoconfigure/GraphQlPlatformStartupValidatorTest.java' +git commit -m "feat: add graphql boot starter validation" +``` + +### Task 47: Cross-module Contract Testkit와 실제 Transport·Storage 검증 + +**Files:** +- Create: `modules/graphql/graphql-testkit-core/src/main/java/io/backend/skeleton/graphql/testkit/GraphQlContractFixture.java` +- Create: `modules/graphql/graphql-testkit-core/src/main/java/io/backend/skeleton/graphql/testkit/GraphQlSchemaContractSuite.java` +- Create: `modules/graphql/graphql-testkit-core/src/main/java/io/backend/skeleton/graphql/testkit/GraphQlHttpContractSuite.java` +- Create: `modules/graphql/graphql-testkit-core/src/main/java/io/backend/skeleton/graphql/testkit/GraphQlSecurityContractSuite.java` +- Create: `modules/graphql/graphql-testkit-core/src/main/java/io/backend/skeleton/graphql/testkit/GraphQlDataLoaderContractSuite.java` +- Create: `modules/graphql/graphql-testkit-core/src/main/java/io/backend/skeleton/graphql/testkit/GraphQlPaginationContractSuite.java` +- Create: `modules/graphql/graphql-testkit-core/src/main/java/io/backend/skeleton/graphql/testkit/GraphQlJpaIntegrationFixture.java` +- Create: `modules/graphql/graphql-testkit-core/src/main/java/io/backend/skeleton/graphql/testkit/GraphQlMongoIntegrationFixture.java` +- Create: `modules/graphql/graphql-testkit-core/src/main/java/io/backend/skeleton/graphql/testkit/GraphQlDownstreamFailureFixture.java` +- Test: `modules/graphql/graphql-testkit-core/src/test/java/io/backend/skeleton/graphql/testkit/GraphQlCrossModuleContractSuiteTest.java` + +**Interfaces:** +- Consumes: `ExecutionGraphQlServiceTester`, `WebGraphQlTester`, `HttpGraphQlTester`, PostgreSQL·MongoDB testkit과 HTTP fault fixture. +- Produces: 같은 operation document를 execution, actual HTTP, security, JPA, Mongo, downstream failure 경로에서 반복 검증하는 reusable suite. + +**Implementation requirements:** +- Schema test는 parse, mapping, scalar, compatibility와 null propagation을 검증한다. +- HTTP test는 preferred/legacy media type, 4xx request error와 HTTP 200 partial field error를 검증한다. +- JPA/Mongo test는 operation별 statement/query count와 DataLoader N+1 방지를 검증한다. +- Security test는 actor·tenant·field·object authorization 우회를 검증한다. +- Downstream failure test는 partial data, error masking, timeout와 cancellation을 검증한다. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlCrossModuleContractSuiteTest { + @org.junit.jupiter.api.Test + void fieldFailureKeepsSiblingDataAndHttp200() { + var fixture = GraphQlContractFixture.standard(); + var response = fixture.executeHttp( + "query Contract { stableField failingField }"); + + org.assertj.core.api.Assertions.assertThat(response.status()) + .isEqualTo(200); + org.assertj.core.api.Assertions.assertThat(response.data()) + .containsKey("stableField"); + org.assertj.core.api.Assertions.assertThat(response.errors()) + .isNotEmpty(); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-testkit-core:test --tests 'io.backend.skeleton.graphql.testkit.GraphQlCrossModuleContractSuiteTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GraphQlContractResponse( + int status, + java.util.Map data, + java.util.List> errors) { +} + +public final class GraphQlContractFixture { + public static GraphQlContractFixture standard() { + return new GraphQlContractFixture(); + } + + public GraphQlContractResponse executeHttp(String document) { + // The concrete fixture boots the owning test application, + // executes the document through HttpGraphQlTester, and maps + // the actual exchange into this stable assertion model. + throw new UnsupportedOperationException( + "implemented by graphql-testkit-http fixture"); + } +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-testkit-core:test --tests 'io.backend.skeleton.graphql.testkit.GraphQlCrossModuleContractSuiteTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-testkit-core/src/main/java/io/backend/skeleton/graphql/testkit/GraphQlContractFixture.java' 'modules/graphql/graphql-testkit-core/src/main/java/io/backend/skeleton/graphql/testkit/GraphQlSchemaContractSuite.java' 'modules/graphql/graphql-testkit-core/src/main/java/io/backend/skeleton/graphql/testkit/GraphQlHttpContractSuite.java' 'modules/graphql/graphql-testkit-core/src/main/java/io/backend/skeleton/graphql/testkit/GraphQlSecurityContractSuite.java' 'modules/graphql/graphql-testkit-core/src/main/java/io/backend/skeleton/graphql/testkit/GraphQlDataLoaderContractSuite.java' 'modules/graphql/graphql-testkit-core/src/main/java/io/backend/skeleton/graphql/testkit/GraphQlPaginationContractSuite.java' 'modules/graphql/graphql-testkit-core/src/main/java/io/backend/skeleton/graphql/testkit/GraphQlJpaIntegrationFixture.java' 'modules/graphql/graphql-testkit-core/src/main/java/io/backend/skeleton/graphql/testkit/GraphQlMongoIntegrationFixture.java' 'modules/graphql/graphql-testkit-core/src/main/java/io/backend/skeleton/graphql/testkit/GraphQlDownstreamFailureFixture.java' 'modules/graphql/graphql-testkit-core/src/test/java/io/backend/skeleton/graphql/testkit/GraphQlCrossModuleContractSuiteTest.java' +git commit -m "test: add graphql cross module contract suites" +``` + +### Task 48: Performance·Fault·Compatibility·Release Gate와 Runbook + +**Files:** +- Create: `modules/graphql/graphql-testkit-core/src/main/java/io/backend/skeleton/graphql/release/GraphQlReleaseGate.java` +- Create: `modules/graphql/graphql-testkit-core/src/main/java/io/backend/skeleton/graphql/release/GraphQlReleaseEvidence.java` +- Create: `modules/graphql/graphql-testkit-core/src/main/java/io/backend/skeleton/graphql/release/GraphQlPerformanceScenario.java` +- Create: `modules/graphql/graphql-testkit-core/src/main/java/io/backend/skeleton/graphql/release/GraphQlFaultScenario.java` +- Create: `modules/graphql/graphql-testkit-core/src/main/java/io/backend/skeleton/graphql/release/GraphQlCompatibilityMatrix.java` +- Create: `modules/graphql/graphql-testkit-core/src/main/java/io/backend/skeleton/graphql/release/GraphQlReleaseReportWriter.java` +- Create: `modules/graphql/graphql-testkit-core/src/main/java/io/backend/skeleton/graphql/release/GraphQlStableCapabilityManifest.java` +- Create: `modules/graphql/graphql-testkit-core/src/main/java/io/backend/skeleton/graphql/release/GraphQlReleaseFailure.java` +- Test: `modules/graphql/graphql-testkit-core/src/test/java/io/backend/skeleton/graphql/release/GraphQlReleaseGateTest.java` + +**Interfaces:** +- Consumes: 모든 Stable contract suite, schema diff, load·fault evidence와 Spring Boot BOM compatibility matrix. +- Produces: Stable 배포를 차단하거나 승인하는 기계 판독 가능한 release evidence와 운영 Runbook 입력. + +**Implementation requirements:** +- PR lane은 schema, unit, architecture, HTTP contract, PostgreSQL·Mongo integration을 실행한다. +- Nightly lane은 query bomb, pool saturation, downstream timeout, cancellation, memory와 event-loop blocking을 실행한다. +- Release lane은 Boot 4.1 BOM, Spring GraphQL 2.0 계열, GraphQL Java Boot-managed v25 조합을 실제 transport로 검증한다. +- Stable gate는 query p95/p99, DB statement count, DataLoader batch ratio, response bytes, allocation, active resolver와 timeout/cancel evidence를 요구한다. +- 검증 실패를 경고로 낮추는 override는 G4 감사와 만료 시각이 있는 승인 레코드 없이는 허용하지 않는다. + +- [ ] **Step 1: Write the failing test** + +```java +class GraphQlReleaseGateTest { + @org.junit.jupiter.api.Test + void missingFaultEvidenceBlocksStableRelease() { + var evidence = GraphQlReleaseEvidence.builder() + .schemaPassed(true) + .contractsPassed(true) + .performancePassed(true) + .faultPassed(false) + .compatibilityPassed(true) + .build(); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> new GraphQlReleaseGate().verify(evidence)) + .isInstanceOf(GraphQlReleaseFailure.class) + .hasMessageContaining("fault"); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the failure** + +Run: + +```bash +./gradlew :modules:graphql:graphql-testkit-core:test --tests 'io.backend.skeleton.graphql.release.GraphQlReleaseGateTest' +``` + +Expected: FAIL because the production contract defined by this task does not exist or rejects the required invariant incorrectly. + +- [ ] **Step 3: Implement the smallest complete production contract** + +```java +public record GraphQlReleaseEvidence( + boolean schemaPassed, + boolean contractsPassed, + boolean performancePassed, + boolean faultPassed, + boolean compatibilityPassed) { + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + private boolean schema; + private boolean contracts; + private boolean performance; + private boolean fault; + private boolean compatibility; + + public Builder schemaPassed(boolean value) { + schema = value; return this; + } + public Builder contractsPassed(boolean value) { + contracts = value; return this; + } + public Builder performancePassed(boolean value) { + performance = value; return this; + } + public Builder faultPassed(boolean value) { + fault = value; return this; + } + public Builder compatibilityPassed(boolean value) { + compatibility = value; return this; + } + public GraphQlReleaseEvidence build() { + return new GraphQlReleaseEvidence( + schema, contracts, performance, fault, compatibility); + } + } +} + +public final class GraphQlReleaseGate { + public void verify(GraphQlReleaseEvidence evidence) { + if (!evidence.schemaPassed() + || !evidence.contractsPassed() + || !evidence.performancePassed() + || !evidence.faultPassed() + || !evidence.compatibilityPassed()) { + throw new GraphQlReleaseFailure( + "schema, contract, performance, fault and " + + "compatibility evidence are all required"); + } + } +} +``` + +Implement all listed files with the exact public names and invariants above. Keep persistence, provider, credential and dynamic identifier types outside the public contract. + +- [ ] **Step 4: Run the focused test and the owning suite** + +Run: + +```bash +./gradlew :modules:graphql:graphql-testkit-core:test --tests 'io.backend.skeleton.graphql.release.GraphQlReleaseGateTest' +./gradlew graphqlStableTest +``` + +Expected: PASS for the focused test and the aggregate suite. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/graphql/graphql-testkit-core/src/main/java/io/backend/skeleton/graphql/release/GraphQlReleaseGate.java' 'modules/graphql/graphql-testkit-core/src/main/java/io/backend/skeleton/graphql/release/GraphQlReleaseEvidence.java' 'modules/graphql/graphql-testkit-core/src/main/java/io/backend/skeleton/graphql/release/GraphQlPerformanceScenario.java' 'modules/graphql/graphql-testkit-core/src/main/java/io/backend/skeleton/graphql/release/GraphQlFaultScenario.java' 'modules/graphql/graphql-testkit-core/src/main/java/io/backend/skeleton/graphql/release/GraphQlCompatibilityMatrix.java' 'modules/graphql/graphql-testkit-core/src/main/java/io/backend/skeleton/graphql/release/GraphQlReleaseReportWriter.java' 'modules/graphql/graphql-testkit-core/src/main/java/io/backend/skeleton/graphql/release/GraphQlStableCapabilityManifest.java' 'modules/graphql/graphql-testkit-core/src/main/java/io/backend/skeleton/graphql/release/GraphQlReleaseFailure.java' 'modules/graphql/graphql-testkit-core/src/test/java/io/backend/skeleton/graphql/release/GraphQlReleaseGateTest.java' +git commit -m "chore: add graphql stable release gate" +``` diff --git a/docs/graphql-superpowers-package/docs/superpowers/specs/2026-08-12-graphql-api-execution-platform-design.md b/docs/graphql-superpowers-package/docs/superpowers/specs/2026-08-12-graphql-api-execution-platform-design.md new file mode 100644 index 00000000..836c9d33 --- /dev/null +++ b/docs/graphql-superpowers-package/docs/superpowers/specs/2026-08-12-graphql-api-execution-platform-design.md @@ -0,0 +1,2553 @@ +# GraphQL API 실행 플랫폼 설계서 + +- **문서 상태:** 구현 기준선 확정 +- **기준일:** 2026-08-12 +- **대상 저장소:** Java/Spring Backend Skeleton +- **Root package:** `io.backend.skeleton.graphql` +- **Stable module root:** `modules/graphql` +- **Advanced module root:** `modules/graphql-advanced` +- **요구사항 원본:** `GraphQL API 실행 플랫폼 심층 리서치` + +## 0. 확정 경계 요약 + +- Stable 기본 토폴로지는 **Single Executable Schema**다. +- **JPA Entity와 MongoDB Document**는 GraphQL Input·Output 계약으로 직접 노출하지 않는다. +- **GraphQL Multipart Upload**는 지원하지 않으며 binary lifecycle은 Fileserver가 소유한다. +- Validation 이후 execution 중 Field Error가 발생해도 가능한 Partial Data는 HTTP `200`으로 반환한다. +- 여러 Mutation Root Field를 하나의 request-wide database transaction으로 묶지 않는다. + +## 1. 문서 목적 + +이 문서는 GraphQL Java와 Spring for GraphQL의 편의 Wrapper를 만드는 문서가 아니다. **SDL로 정의된 외부 API 계약이 인증, 요청 제한, parse·validation, operation 정책, 비용 판정, resolver, DataLoader, Application Use Case, partial data와 error, 실시간 stream으로 실행되는 전 과정**을 통제하는 플랫폼의 설계 기준을 확정한다. + +구현자가 다시 결정하지 않도록 다음 항목을 명시적으로 고정한다. + +```text +Schema 소유권과 조립 방식 +Transport 지원 범위 +Request Context +Resolver와 Application Service 경계 +Blocking·Reactive 실행 Profile +DataLoader 요청 범위와 Batch 계약 +Selection Set과 Fetch Profile +Cursor 형식과 서명 +Mutation·Transaction·Idempotency 의미 +Error wire contract +Field·Object·Tenant 권한 +Query cost와 abuse control +Preparsed cache와 Persisted Operation 경계 +Subscription의 delivery 한계 +Schema compatibility와 Release Gate +``` + +## 2. 핵심 결론 + +```text +GraphQL Platform += Schema Contract ++ Transport Profile ++ Execution Policy ++ Governance ++ Security ++ Verification + +GraphQL Platform +≠ Database Gateway +≠ JPA/Mongo Repository 자동 노출기 +≠ Durable Messaging Broker +≠ Binary Upload Server +≠ Request-wide Database Transaction Manager +``` + +플랫폼은 GraphQL operation을 Application Use Case에 연결한다. 저장소와 외부 시스템의 고유 의미론은 기존 JPA, MongoDB, HTTP Client, Messaging, Fileserver, Object Storage 모듈이 계속 소유한다. + +### 2.1 GraphQL 플랫폼이 소유한다 + +- SDL resource discovery, assembly, validation, fingerprint +- Schema 변경 호환성 정책과 deprecation removal gate +- HTTP, WebSocket, SSE transport profile +- immutable `GraphQlRequestContext` +- client·operation policy manifest +- parser, depth, field, alias, fragment, complexity, response budget +- resolver return/input type와 application boundary +- DataLoader request scope, batch, timeout, missing key, key error +- Selection Set을 유한한 Fetch Profile로 분류하는 규칙 +- signed cursor envelope +- GraphQL error wire contract와 내부 오류 마스킹 +- persisted operation registry와 operation block +- subscription connection, auth, buffer, ordering 정책 +- metric·trace cardinality policy +- schema·transport·security·performance release gate + +### 2.2 도메인·Application 모듈이 소유한다 + +- Query, Mutation, Subscription의 업무 의미 +- GraphQL Input·Output DTO와 Read Model +- Application Use Case +- 도메인 상태가 필요한 authorization decision +- transaction 요구 +- 저장소 query, fetch, pagination 의미 +- integration event의 업무 의미 +- 예상 가능한 typed business result + +### 2.3 기존 기술 모듈이 소유한다 + +| 영역 | 기존 모듈 책임 | GraphQL 책임 | +|---|---|---| +| JPA | Entity, Repository, Transaction, Fetch Plan, Lock | Fetch Profile을 선택해 Application Query 호출 | +| MongoDB | Document, Query, Aggregation, Consistency | Fetch Profile을 선택해 Application Query 호출 | +| HTTP Client | Timeout, Retry, TLS, SSRF, Bulkhead | Application 결과를 GraphQL DTO로 조립 | +| Messaging | ACK, Replay, DLQ, durable event | Subscription source에 event 제공 | +| Fileserver | Binary lifecycle, 검사, Range, upload | Upload reservation과 file metadata 반환 | +| Object Storage | bytes, checksum, delegated access | 직접 호출하지 않음 | +| WebSocket | 범용 연결 인프라 | GraphQL subscription protocol과 execution | + +## 3. 지원 기준 + +| 구성 | 기준 | 등급 | +|---|---|---| +| Java | 21 | Stable | +| Spring Boot | 프로젝트 4.1 BOM | Source of Truth | +| Spring for GraphQL | 2.0 계열 | Stable | +| GraphQL Java | Boot-managed v25 계열 | Stable | +| GraphQL Specification | September 2025 | Contract | +| GraphQL over HTTP | Stage 2 Draft | Versioned compatibility profile | +| HTTP POST | MVC·WebFlux | Stable | +| WebSocket | `graphql-transport-ws` | Advanced Stable | +| SSE | Distinct Connection | Advanced | +| RSocket | Spring Extension | Experimental | +| Federation Subgraph | federation-jvm | Advanced | +| HTTP GET | Draft compatibility | Experimental | +| Multipart Upload | Fileserver 사용 | Unsupported | +| HTTP array batch | Core 밖 | Unsupported | +| Incremental Delivery | 별도 실험 | Experimental | + +Spring Boot BOM이 Spring for GraphQL과 GraphQL Java 조합의 기준이다. GraphQL Java 직접 override는 보안 대응이나 신규 기능 검증을 위한 별도 compatibility lane에서만 허용한다. + +### 3.1 HTTP Draft 상태 코드 결정 + +Stable HTTP profile은 Spring for GraphQL 2.0 계열의 실제 동작을 계약으로 삼는다. + +```text +malformed JSON / parse / validation / coercion +→ application/graphql-response+json에서 4xx + +validation을 통과해 execution 시작 후 field error +→ HTTP 200 + data/errors + +partial data + errors +→ HTTP 200 +``` + +이동 중인 GraphQL over HTTP Draft의 294 제안은 Stable에 선제 도입하지 않는다. Draft 변화는 별도 compatibility job에서 추적한다. + +## 4. 공개 기능 계층 + +```text +G1 Standard GraphQL API +- SDL +- Query / Mutation +- Annotated Controller +- HTTP POST +- Error Contract +- Request Context +- DataLoader +- Cursor Connection +- Cost Control + +G2 Advanced Execution +- Persisted Operation +- Registered Fetch Profile +- WebSocket / SSE Subscription +- DataLoader Chaining +- Advanced Directive / Scalar + +G3 Extension +- Federation Subgraph +- RSocket +- Code Generation +- Spring Data Compatibility +- HTTP Draft Compatibility +- Incremental Delivery + +G4 Admin Plane +- Schema Diff +- Persisted Operation 등록·차단 +- Schema Usage +- Cost Profile +- Subscription Runtime 진단 +- Federation Composition +``` + +일반 애플리케이션에는 `graphql.GraphQL`, 자유형 `GraphQLCodeRegistry`, raw `DataFetcher` registry를 공개하지 않는다. Infrastructure SPI 또는 명시적 G3 모듈에서만 사용한다. + +## 5. 확정 모듈 구조 + +```text +modules/graphql/ +├── graphql-core-api +├── graphql-schema +├── graphql-execution +├── graphql-controller +├── graphql-http +├── graphql-dataloader +├── graphql-pagination +├── graphql-security +├── graphql-cost-control +├── graphql-error +├── graphql-observability +├── graphql-spring-boot-starter +├── graphql-testkit-core +├── graphql-testkit-schema +├── graphql-testkit-http +└── graphql-testkit-integration + +modules/graphql-advanced/ +├── graphql-persisted-operation +├── graphql-admin +├── graphql-subscription +├── graphql-websocket +├── graphql-sse +├── graphql-federation +├── graphql-codegen +├── graphql-spring-data-compat +├── graphql-rsocket +├── graphql-http-draft-compat +├── graphql-incremental-experimental +├── graphql-testkit-realtime +└── graphql-testkit-federation +``` + +### 5.1 Stable dependency map + +```text +graphql-core-api + → Java standard library only + +graphql-schema + → core-api + → GraphQL Java schema API + +graphql-execution + → core-api + → schema + → Spring GraphQL execution API + +graphql-controller + → core-api + → execution + → Spring annotated controller + +graphql-http + → core-api + → execution + → Spring MVC / WebFlux + +graphql-dataloader / pagination / security / cost-control / error + → core-api + → execution + +graphql-observability + → core-api + → execution + +graphql-spring-boot-starter + → every Stable runtime module + → no Advanced module +``` + +## 6. 중심 공개 계약 + +```java +public record GraphQlSchemaContract( + String schemaHash, + String breakingPolicyVersion, + String scalarManifestVersion, + String directiveManifestVersion) {} + +public record GraphQlRequestContext( + ActorRef actor, + TenantContext tenant, + GraphQlClientProfile clientProfile, + Locale locale, + GraphQlOperationId operationId, + String traceId, + GraphQlDeadline deadline) {} + +public record GraphQlClientPolicy( + int maxDocumentBytes, + int maxVariablesBytes, + int maxDepth, + int maxFields, + int maxAliases, + int maxFragments, + int maxInputListElements, + int defaultPageSize, + int maxPageSize, + long maxComplexity, + long maxResponseNodes, + long maxResponseBytes, + Duration maxExecutionTime, + boolean introspectionAllowed, + boolean persistedOperationOnly, + boolean namedOperationRequired) {} + +public record GraphQlBatchPolicy( + String loaderName, + int maxBatchSize, + Duration timeout, + MissingKeyPolicy missingKeyPolicy, + BatchErrorPolicy errorPolicy) {} +``` + +```java +public interface GraphQlFetchProfileRegistry { + GraphQlFetchProfile select( + GraphQlSchemaCoordinate coordinate, + Set selectedFields, + GraphQlClientProfile clientProfile); +} + +public interface GraphQlCursorCodec { + String encode(GraphQlCursorEnvelope cursor); + GraphQlCursorEnvelope decode(String encoded); +} + +public interface GraphQlErrorContract { + GraphQlWireError map(Throwable failure, GraphQlErrorContext context); +} +``` + +## 7. Schema 계약 + +### 7.1 SDL First + +SDL이 외부 API 계약의 Source of Truth다. 도메인 모듈이 자기 schema fragment를 소유하고 플랫폼이 조립·검증한다. + +```text +modules/order/src/main/resources/graphql/order/ +├── order-type.graphqls +├── order-query.graphqls +└── order-mutation.graphqls + +modules/graphql/graphql-schema/src/main/resources/graphql/common/ +├── scalar.graphqls +├── directive.graphqls +├── connection.graphqls +└── error.graphqls +``` + +### 7.2 Input·Output·Persistence 분리 + +- JPA Entity와 Mongo Document를 GraphQL output으로 반환하지 않는다. +- GraphQL input을 Entity·Document에 직접 bind하지 않는다. +- Provider SDK 객체와 자유형 `Map`를 wire contract로 쓰지 않는다. +- generated type은 client 또는 transport DTO까지만 허용한다. + +### 7.3 Nullability + +Non-null은 DB column의 `NOT NULL`이 아니다. **Resolver, authorization, dependency failure까지 포함해 항상 값을 제공한다는 API 보장**이다. + +- identity는 `ID!` 후보 +- 외부 enrichment와 부분 실패 가능한 child는 nullable 우선 +- 신규 field는 nullable로 도입하고 보장을 검증한 뒤 강화 +- Non-null 변경은 schema diff와 data/resolver contract test 요구 +- null propagation 경계를 golden response로 고정 + +### 7.4 Scalar + +| Scalar | 정책 | +|---|---| +| ID | opaque string | +| UUID | canonical string | +| Instant | UTC ISO-8601 | +| Date | ISO local date | +| BigDecimal | precision-loss 없는 coercion | +| Long | client numeric range 정책 | +| URL | Advanced parser·normalization | +| Email | format 검증만, ownership은 업무 | +| JSON | allowlist된 coordinate만 | +| Upload | 금지 | + +### 7.5 `@oneOf` + +September 2025 규격의 `@oneOf`를 Stable 기능으로 지원한다. 정확히 하나의 nullable field가 non-null 값으로 제공되어야 하며 default value를 허용하지 않는다. Schema build와 coercion contract test를 Release Gate에 포함한다. + +## 8. Schema assembly·검증·진화 + +Startup과 CI에서 다음 순서를 실행한다. + +```text +SDL resource discovery +→ parse +→ schema validation +→ duplicate type / field / directive +→ scalar / directive wiring +→ interface / union TypeResolver +→ SchemaMappingInspector +→ argument / nullability mapping +→ forbidden feature scan +→ compatibility diff +→ schema contract fingerprint +``` + +Stable profile에서는 unmapped field, unknown resolver, argument mismatch, nullability mismatch가 startup 실패다. + +### 8.1 Compatibility 정책 + +- field 삭제·rename, required argument 추가, input 강화, output nullable 전환은 Breaking +- output enum/union possible type 추가는 wire additive이나 generated-client review 요구 +- scalar coercion 변경은 새 scalar/version +- directive 의미 변경은 behavioral compatibility review +- deprecated element 삭제 전 usage, persisted operation reference, support window, client owner 승인 확인 + +```text +@deprecated +→ Schema Usage Observation +→ Persisted Operation Reference Scan +→ Support Window +→ Client Owner Approval +→ Removal +``` + +## 9. HTTP Transport Profile + +### 9.1 Stable profile + +```text +Method: POST only +Content-Type: application/json +Accept: application/graphql-response+json preferred +Legacy response: application/json compatible +Request fields: query, operationName, variables, extensions +``` + +### 9.2 요청 제한 + +- request bytes와 variable bytes를 GraphQL parse 전에 제한 +- `variables`와 `extensions`는 object만 허용 +- `extensions` key allowlist +- Production named operation 필수 +- Cookie 인증 profile은 CSRF 필수 +- HTTP GET, array batch, multipart upload는 Stable 비지원 + +### 9.3 파일 Upload + +```text +GraphQL Mutation +→ Fileserver upload reservation / ticket 생성 + +Client +→ Fileserver 또는 Object Storage로 binary 전송 + +GraphQL Query +→ file metadata와 상태 조회 +``` + +GraphQL `Upload` scalar, multipart parser, checksum, quarantine, Range download를 구현하지 않는다. + +## 10. 실행 Profile + +```text +BLOCKING_MVC +- JPA / blocking Mongo / blocking SDK +- Java 21 virtual thread 또는 bounded executor + +REACTIVE_WEBFLUX +- Reactive Mongo / WebClient / subscription +- Reactor Context +- event-loop blocking 금지 + +MIXED_CONTROLLED +- 명시적 bridge와 executor/scheduler 전환 +- 아무 반환형이나 자동 허용하지 않음 +``` + +Resolver catalog에는 `BLOCKING`, `ASYNC`, `REACTIVE`, `STREAM`을 등록한다. Runtime profile과 실행 유형이 충돌하면 startup 또는 architecture test에서 실패한다. + +Timeout은 분리한다. + +```text +transportHandshakeTimeout +requestExecutionTimeout +resolverBudget +dataLoaderBatchTimeout +subscriptionIdleTimeout +subscriptionMaxAge +shutdownDrainTimeout +``` + +GraphQL deadline은 JPA, MongoDB, HTTP Client의 하위 deadline에 전파한다. Timeout 이후 reactive publisher와 실제 resource가 취소되는지 검증한다. + +## 11. Resolver와 Application Service 경계 + +일반 진입점은 `@QueryMapping`, `@MutationMapping`, `@SubscriptionMapping`, `@SchemaMapping`, `@BatchMapping`이다. + +Resolver가 수행한다. + +- GraphQL input을 application command/query로 변환 +- Bean Validation +- Actor·Tenant·Locale·Deadline 전달 +- Application Use Case 호출 +- GraphQL DTO·Payload·Connection 변환 + +Resolver가 수행하지 않는다. + +- `EntityManager`, `MongoTemplate` 직접 Query +- HTTP retry·circuit breaker +- 메시지 ACK·DLQ +- Object Storage binary I/O +- multi-step domain transition +- provider exception 공개 + +Transaction annotation은 Resolver가 아니라 Application Service에 둔다. + +## 12. Selection Set과 Fetch Profile + +Selection Set을 자유형 SQL/Mongo projection으로 변환하지 않는다. + +```text +Selection Set +→ SelectionClassifier +→ registered finite FetchProfile +→ Application Query +→ JPA/Mongo implementation +``` + +예: + +```text +Order.BASIC +Order.WITH_ITEMS +Order.WITH_CUSTOMER +Order.FULL_DETAIL +``` + +실제 EntityGraph, DTO projection, JPQL, Native SQL, Mongo aggregation은 저장소 모듈이 소유한다. 등록되지 않은 조합은 명시적 fallback 또는 오류로 처리한다. + +## 13. DataLoader 계약 + +- DataLoader instance와 cache는 GraphQL execution 요청 범위 +- cross-request cache는 Redis/Application Cache로 분리 +- Actor·Tenant·Locale·Deadline을 loader context에 전달 +- batch size는 JPA IN, Mongo `$in`, downstream batch API 상한으로 제한 +- ordered loader는 입력 key 순서를 유지 +- mapped loader는 missing key 정책 명시 +- key별 failure와 batch 전체 failure 분리 +- timeout, cancel, metric을 loader 단위로 제공 +- chained dispatch는 Advanced opt-in + +DataLoader는 root query의 과도한 Entity graph, Cartesian product, 잘못된 index, unbounded child collection을 해결하지 않는다. + +## 14. Cursor Connection + +Cursor는 Base64만 적용한 JSON이 아니라 versioned authenticated envelope다. + +```json +{ + "v": 1, + "profile": "orders-by-created-at", + "direction": "FORWARD", + "keyset": { + "createdAt": "2026-08-12T01:00:00Z", + "id": "..." + }, + "filter": "sha256:...", + "kid": "cursor-key-2026-01", + "mac": "..." +} +``` + +- query profile, filter fingerprint, sort tie-breaker 고정 +- HMAC key rotation +- unknown version, signature mismatch, key mismatch, profile mismatch 거부 +- page size를 decode 후 서버 정책으로 재검증 +- `totalCount`는 opt-in resolver +- JPA는 keyset/Scroll, MongoDB는 range+`_id`, 외부 API는 upstream cursor를 signed envelope 안에 보관 + +## 15. Mutation·Transaction·Idempotency + +```text +Mutation root field serial execution +≠ request-wide DB transaction +``` + +하나의 Mutation Resolver는 하나의 Application Use Case를 호출한다. 여러 변경이 원자적이어야 하면 하나의 명시적 Use Case Mutation을 제공한다. + +Optimistic version과 idempotency key는 Application command로 전달한다. + +```text +idempotency scope += actor/client identity ++ mutation coordinate ++ idempotency key ++ normalized input fingerprint +``` + +동일 key와 다른 fingerprint는 conflict다. GraphQL transport는 DB retry, idempotency record, lock을 직접 구현하지 않는다. + +예상 가능한 업무 분기는 typed payload/union으로 표현할 수 있다. 예기치 않은 dependency/internal failure는 GraphQL error로 남긴다. + +## 16. Error wire contract + +```text +REQUEST_ERROR +- malformed JSON +- parse +- validation +- variable coercion + +FIELD_ERROR +- resolver execution +- partial data 가능 + +BUSINESS_RESULT +- 예상 가능한 업무 결과 +- typed payload/union 우선 + +INTERNAL_ERROR +- 예상 밖 장애 +- opaque message + executionId +``` + +공개 `extensions` allowlist: + +```text +code +category +retryable +executionId +constraint +safe logical field +``` + +공개 금지: + +```text +Java exception class +stack trace +SQL / JPQL / Mongo query +downstream URL과 provider body +credential / token +internal host +raw tenant/user/object ID +``` + +Non-null propagation은 오류 계약과 함께 golden test로 고정한다. + +## 17. Security + +```text +Transport Authentication +→ Client Profile Authorization +→ Operation Authorization +→ Field / Use Case Authorization +→ Object Authorization +→ Tenant Isolation +``` + +Schema visibility는 authorization을 대체하지 않는다. Tenant는 GraphQL argument가 아니라 인증 Context에서 결정한다. BatchLoader도 동일한 Actor·Tenant Context를 사용한다. + +환경별 정책: + +| 환경 | Introspection | GraphiQL | +|---|---|---| +| Local | 허용 | 허용 | +| Test | 허용 | 선택 | +| Dev | 인증된 사용자 | 인증된 사용자 | +| Staging | Admin/CI | 비활성 | +| Prod internal | Client Profile 기준 | 비활성 | +| Prod public | 제한 또는 비활성 | 비활성 | + +Introspection 차단만으로 보안을 완성하지 않는다. Field/Object authorization, cost limit, persisted operation, request size limit을 함께 적용한다. + +## 18. Cost·DoS control + +방어 순서: + +```text +HTTP body bytes +→ variables bytes +→ request envelope +→ parser character/token/grammar limits +→ parse +→ validation +→ operation count/name/type +→ introspection policy +→ depth/fields/aliases/fragments +→ input list/string +→ complexity +→ estimated response nodes +→ execution timeout +→ actual response bytes +``` + +Complexity는 cardinality와 resolver class를 포함한다. + +```text +field cost += base field cost ++ resolver weight ++ child cost × effective cardinality +``` + +Resolver catalog 예: + +| 유형 | 상대 비용 | +|---|---:| +| in-memory scalar/property | 1 | +| indexed DB lookup | 2 | +| batched relation | 3 | +| bounded aggregation | 8 | +| external batch API | 10 | +| external per-object call | 20 | +| search/heavy aggregation | 별도 승인 | + +숫자는 표준값이 아니라 calibration 시작점이며 실제 latency, DB query, examined rows/documents, downstream call과 비교해 profile manifest에서 조정한다. + +## 19. Cache와 Persisted Operation 경계 + +```text +DataLoader Cache +→ request internal data loading + +Preparsed Document Cache +→ parse/validation result + +Persisted Operation Registry +→ approved operation document + +Response Cache +→ Stable 초기 비지원 +``` + +Preparsed cache key: + +```text +documentHash +schemaContractHash +validationPolicyVersion +clientSchemaProfile +``` + +Persisted Operation은 Advanced 모듈에서 다음을 소유한다. + +```text +operationId +operationName +sha256Document +canonicalDocument +schemaContractHash +allowedClientProfiles +maximumComplexity +maximumVariablesBytes +status: ACTIVE | DEPRECATED | BLOCKED +``` + +Incident 시 특정 operation을 application redeploy 없이 차단할 수 있어야 한다. + +## 20. Subscription + +Subscription은 live response stream이며 durable messaging이 아니다. + +```text +Messaging +→ persistence / ACK / replay / retry / DLQ + +GraphQL Subscription +→ client selection / connected actor / authorization / live delivery / cancellation +``` + +상태: + +```text +CONNECTING → AUTHENTICATING → READY → SUBSCRIBED → STREAMING +→ CANCELLING → COMPLETED + +AUTH_EXPIRED / SLOW_CONSUMER / SOURCE_FAILED / SERVER_DRAINING / PROTOCOL_ERROR +``` + +정책: + +- `graphql-transport-ws` 사용 +- connection-init timeout, max subscription, max connection age +- credential expiry 시 connection 종료 +- 민감한 profile은 event 전달 시 재인가 +- bounded buffer +- 기본 slow-consumer 정책은 연결 종료 +- `LOW_LATENCY`와 `ORDERED` 분리 +- replay는 표준 보장이 아니며 Messaging 기반 Advanced extension +- SSE는 subscription-only Distinct Connection + +## 21. Federation·Codegen·Spring Data Compatibility + +### 21.1 Federation + +- 단일 executable schema가 Stable 기본 +- Federation Subgraph는 독립 배포, 실제 schema ownership, composition CI, router owner, distributed trace, latency budget이 있을 때만 opt-in +- Federation Router는 이 저장소 밖 +- entity key는 owner, stability, deprecation policy 필요 +- entity resolution은 request-scoped batch와 authorization 사용 + +### 21.2 Code Generation + +허용: + +```text +client request/response +operation validation +transport-only DTO +``` + +금지: + +```text +Domain Entity +Application Use Case +Repository +Persistence model +``` + +### 21.3 Spring Data Compatibility + +`@GraphQlRepository` 자동 노출은 별도 compatibility 모듈에서 allowlist한다. Filter, sort, projection, pagination 정책을 명시하고 offset pagination 기본값을 조용히 채택하지 않는다. + +## 22. Observability + +Spring의 `graphql.request`, `graphql.datafetcher`, `graphql.dataloader` observation을 재사용하고 플랫폼은 naming과 cardinality를 통제한다. + +허용 tag: + +```text +operationName +operationType +clientProfile +persisted +outcome +errorCategory +complexityBucket +depthBucket +bounded schemaCoordinate +bounded loaderName +``` + +금지: + +```text +raw query +variables +userId +raw tenantId +objectId +cursor +token +connection_init payload +arbitrary full field path +``` + +Trace: + +```text +HTTP/WebSocket receive +→ GraphQL request +→ resolver +→ DataLoader +→ Application Use Case +→ DB/HTTP/Messaging +``` + +## 23. Configuration과 startup validation + +```yaml +backend: + graphql: + execution-profile: BLOCKING_MVC + http-profile: V1 + schema: + mapping-inspection: FAIL + compatibility-policy: stable-v1 + security: + production-named-operation-required: true + graphiql-enabled: false + clients: + first-party: + max-document-bytes: 65536 + max-variables-bytes: 65536 + max-depth: 12 + max-fields: 500 + max-aliases: 50 + max-fragments: 50 + default-page-size: 20 + max-page-size: 100 + max-complexity: 10000 + max-response-nodes: 10000 + max-response-bytes: 5242880 + max-execution-time: 5s +``` + +위 숫자는 예시이며 benchmark 후 profile manifest에서 확정한다. + +Startup 실패 조건: + +- schema mapping mismatch +- duplicate scalar/directive +- forbidden `Upload` scalar +- Stable에서 HTTP GET, batch, multipart 활성화 +- Production anonymous operation 허용 +- Production GraphiQL 활성화 +- unknown client policy +- resolver execution type과 runtime profile 충돌 +- unsigned cursor codec +- response cache 활성화 +- Stable Starter에 Advanced module 자동 포함 + +## 24. 테스트 전략 + +### 24.1 Contract + +- SDL parse, assembly, mapping, compatibility +- HTTP media, request error 4xx, execution error 200, partial data +- immutable request context와 actor/tenant isolation +- resolver return/input type와 direct repository access 금지 +- DataLoader batch, missing, per-key error, cache scope +- signed cursor와 forward/backward pagination +- mutation transaction, optimistic conflict, idempotency +- error masking과 null propagation +- parser, alias, fragment, complexity, response budget +- observation cardinality + +### 24.2 Storage·Integration + +- JPA query count, entity load, fetch profile +- Mongo examined documents, query count, keyset +- HTTP batch downstream와 bulkhead +- Fileserver upload ticket only + +### 24.3 Performance·Fault + +- named high concurrency +- deep valid query +- wide alias/fragment bomb +- nested connection +- DataLoader saturation +- DB pool saturation +- downstream timeout +- large response serialization +- virtual thread saturation +- event-loop blocking +- cancellation leak + +### 24.4 Realtime Advanced + +- connection-init auth +- token expiry +- slow consumer +- source failure +- ordered/low-latency +- cancel storm +- rolling deployment +- graceful drain +- 1k baseline과 목표 connection soak + +## 25. 지원 등급 + +| 기능 | 등급 | +|---|---| +| SDL, Query/Mutation, HTTP POST | Stable | +| Partial data/error | Stable | +| Context·Security | Stable | +| DataLoader | Stable | +| signed Cursor Connection | Stable | +| Cost Control | Stable | +| Preparsed Cache | Stable | +| MVC VT / WebFlux profile | Stable | +| Fetch Profile | Stable platform capability | +| Persisted Operation | Advanced Stable | +| WebSocket Subscription | Advanced Stable | +| SSE | Advanced | +| Federation Subgraph | Advanced | +| Codegen | Optional | +| DataLoader chaining | Advanced | +| RSocket·HTTP GET | Experimental | +| Incremental Delivery | Experimental/disabled | +| Multipart Upload·HTTP batch | Unsupported | +| Entity/Document auto exposure | Unsupported default | +| request-wide DB transaction | Unsupported default | +| Response Cache | Initial unsupported | + +## 26. 구현 단계 + +```text +Foundation +→ Schema / HTTP / Context / Resolver / Error / Security + +Execution Safety +→ DataLoader / Pagination / Cost / Timeout / Observability + +Storage Integration +→ Fetch Profile / JPA / Mongo / Downstream Batch + +Governance +→ Persisted Operation / Usage / Admin + +Realtime +→ WebSocket / SSE / Backpressure / Auth Lifecycle + +Extension +→ Federation / Codegen / RSocket / HTTP Draft / Incremental +``` + +## 27. Definition of Done + +Stable 플랫폼은 다음 조건을 모두 만족한다. + +- Entity·Document가 GraphQL wire type으로 노출되지 않는다. +- Schema mapping mismatch가 startup/CI에서 실패한다. +- Schema breaking change가 Release Gate에서 차단된다. +- HTTP 4xx/200 partial-error 계약이 고정된다. +- N+1 회귀 테스트가 실제 PostgreSQL·MongoDB에서 통과한다. +- Cursor 변조, filter/profile mismatch가 거부된다. +- Query cost와 response size가 실행 전·중 제한된다. +- internal exception, raw query, variables, PII가 응답·metric에 노출되지 않는다. +- timeout 이후 하위 작업이 취소되고 resource가 누수되지 않는다. +- Stable Starter가 Advanced 기능을 자동 활성화하지 않는다. +- 실제 부하·장애 증거와 운영 Runbook이 존재한다. + +## 28. 명시적 비지원 + +```text +GraphQL multipart upload +HTTP array batching +Arbitrary JSON input gateway +Persistence entity auto exposure +GraphQL request-wide transaction +Durable subscription guarantee +Exactly-once subscription delivery +Raw GraphQL engine access for application code +Unbounded list and totalCount-by-default +Response cache without actor/tenant/permission model +``` + +--- + +# 부록 A. 요구사항 추적표 + +| 리서치 영역 | 설계 반영 | +|---|---| +| 조사 결론과 지원 기준 | §2~§6 | +| Schema 계약과 진화 정책 | §7~§8 | +| Transport와 실행·데이터 접근 계약 | §9~§12 | +| DataLoader·Pagination·Mutation·Error | §13~§16 | +| 보안·비용 통제·Persisted Operation | §17~§19 | +| Subscription·Federation·Codegen·관측성 | §20~§22 | +| 테스트·지원 등급·구현 순서 | §24~§27 | + +# 부록 B. 입력 심층 리서치 원문 + +> 아래 원문은 설계 결정의 근거와 세부 제약을 보존하기 위해 첨부한다. 상단 설계 계약이 구현 기준이다. + +# GraphQL API 실행 플랫폼 심층 리서치 + +## 조사 결론과 지원 기준 + +이번 `graphql` 모듈의 적절한 정체성은 **GraphQL Java/Spring for GraphQL의 편의 Wrapper가 아니라, 외부 Schema 계약이 실제 Application Use Case로 실행되는 전 과정을 통제하는 API 실행 플랫폼**입니다. GraphQL Java는 `GraphQLSchema`, `DataFetcher`, 실행 전략과 `ExecutionResult`를 제공하는 실행 엔진이고, Spring for GraphQL은 이를 Spring의 transport, annotated controller, context propagation, exception resolution, DataLoader, Spring Data 통합과 연결합니다. Spring for GraphQL의 현재 Data Integration 문서 역시 GraphQL을 Selection Set을 SQL이나 JSON query로 일대일 번역하는 데이터 게이트웨이로 보지 않으며, client selection과 server-side projection을 상호 보완적인 것으로 설명합니다. citeturn16search4turn24view0 + +따라서 핵심 경계는 다음으로 확정하는 것이 가장 안전합니다. + +```text +Client + ↓ +GraphQL Transport + ↓ +Authentication + GraphQlRequestContext + ↓ +Parse / Validation + ↓ +Operation Policy / Cost / Persisted Operation + ↓ +Resolver / DataFetcher + ↓ +DataLoader + ↓ +Application Use Case + ↓ +JPA / MongoDB / HTTP Client / Messaging / Fileserver + ↓ +Read Model / DTO + ↓ +GraphQL Completion + Partial Data + Error + ↓ +HTTP Response / Subscription Stream +``` + +```text +GraphQL Platform owns +├─ SDL contract +├─ transport profile +├─ execution policy +├─ request context +├─ resolver conventions +├─ DataLoader contract +├─ cursor envelope +├─ error wire contract +├─ cost / abuse control +├─ persisted operation +├─ subscription delivery contract +├─ schema governance +├─ observability +└─ test / release gates + +Domain/Application owns +├─ use cases +├─ authorization decision requiring domain state +├─ DTO / read models +├─ transaction requirements +├─ pagination query semantics +├─ filter / sort semantics +└─ integration-event meaning + +Persistence/Integration modules own +├─ JPA fetch/query/transaction +├─ Mongo consistency/aggregation/index +├─ HTTP timeout/retry/TLS +├─ Messaging ACK/replay/DLQ +├─ Fileserver binary lifecycle +└─ Object Storage bytes +``` + +### 기술 기준선 + +2026년 8월 12일 기준 공식 문서에서 Spring Boot의 현재 stable은 `4.1.0`, Spring for GraphQL의 최신 stable은 `2.0.4`, GraphQL Java 공식 v25 문서는 `25.0`을 사용 버전으로 제시합니다. 완성된 GraphQL 언어·실행 규격은 **September 2025 Edition**입니다. 반면 GraphQL over HTTP는 현재도 **Stage 2 Draft**이며, 문서 자체가 production에서 draft를 고정 규격처럼 의존하는 것을 경고합니다. 따라서 HTTP 표준은 “지원 Profile”로 고정하고 사양 변화에 대한 별도 compatibility lane을 운영해야 합니다. citeturn19search4turn14search3turn16search12turn15search0turn22view0 + +| 영역 | 권장 기준 | 플랫폼 판정 | +|---|---|---| +| Java | 21 | Stable baseline | +| Spring Boot | 프로젝트 4.1 BOM | Source of Truth | +| Spring for GraphQL | 2.0 계열, 현재 2.0.4 | Stable | +| GraphQL Java | Boot 조합 우선, 현재 v25 계열 | Stable | +| GraphQL Specification | September 2025 | Contract | +| GraphQL over HTTP | Stage 2 Draft | Compatibility Profile | +| HTTP | Spring MVC / WebFlux | Stable | +| WebSocket | Spring GraphQL + `graphql-ws` protocol | Advanced Stable | +| SSE | Spring `GraphQlSseHandler` | Advanced | +| RSocket | Spring 전용 Extension | Experimental | +| Federation Subgraph | `federation-jvm` 통합 | Advanced | +| Federation Router | Core 밖 | Experimental/별도 프로젝트 | +| Multipart Upload | 지원하지 않음 | Unsupported | +| HTTP multi-operation batch | 초기 지원하지 않음 | Unsupported | +| Reactive | Reactor | Stable profile | +| Virtual Thread | Java 21 + Spring executor | Stable profile | + +**중요한 최신 조사 결과가 하나 있습니다.** 현재 GraphQL over HTTP Draft의 상태 코드 부분에는 partial `data + errors`에 `294`를 제안하는 새 문구가 들어가 있는 반면, Spring for GraphQL 2.0.4는 GraphQL request가 validation을 통과한 뒤 발생한 execution/field error를 HTTP `200`으로 반환한다고 명시합니다. 같은 Draft의 field-error 설명에도 실행된 operation의 field error는 `200`이라는 규칙이 남아 있어, 이 영역은 현재 이동 중인 표준입니다. 따라서 **Stable 플랫폼에서 294를 선제 도입하지 말고 Spring 2.0.4의 실제 동작을 계약으로 고정**한 뒤, HTTP Draft 호환 Job에서 변화만 추적하는 것이 맞습니다. citeturn16search10turn23view1 + +### 공개 기능 계층 + +| 계층 | 공개 대상 | 범위 | 정책 | +|---|---|---|---| +| **G1 Standard GraphQL API** | 일반 도메인 개발자 | SDL, Query/Mutation, annotated resolver, HTTP, validation, error, DataLoader, cursor pagination | 기본 Starter | +| **G2 Advanced Execution** | 복잡한 API 개발자 | persisted operation, fetch profile, custom directive/scalar, advanced cost, subscription, SSE | 명시 opt-in | +| **G3 GraphQL Extension** | 전문 통합 코드 | Federation, RSocket, provider codegen, custom WebSocket extension | 별도 모듈 | +| **G4 Admin Plane** | 운영·CI/CD | schema diff, persisted-op registry, operation block, cost profile, usage, composition | 애플리케이션 resolver와 분리 | + +일반 애플리케이션에는 `graphql.GraphQL`이나 자유형 `GraphQLCodeRegistry`를 기본 API로 공개하지 않는 편이 좋습니다. Spring Boot 자체도 일반적인 애플리케이션은 직접 `DataFetcher`를 작성하기보다 annotated controller를 사용하도록 안내하고, `RuntimeWiringConfigurer`는 scalar, directive, type resolver 같은 infrastructure extension을 위한 진입점으로 제공합니다. citeturn19search1turn18search2 + +권장 의존 구조는 다음과 같습니다. + +```text +graphql-core-api + ↑ +graphql-schema + ↑ +graphql-execution + ↑ +graphql-controller + ↑ +graphql-spring-boot-starter + +graphql-http ─────────┐ +graphql-websocket ────┤ +graphql-sse ──────────┤→ graphql-execution +graphql-dataloader ───┤ +graphql-pagination ───┤ +graphql-security ─────┤ +graphql-cost-control ─┤ +graphql-error ────────┤ +graphql-observability ┘ + +Optional: +graphql-persisted-operation +graphql-codegen +graphql-federation +graphql-spring-data-compat +graphql-testkit-* +``` + +특히 `graphql-spring-data-compat`를 별도 모듈로 두는 근거가 분명합니다. Spring for GraphQL 2.0.4는 `@GraphQlRepository`로 Querydsl/QBE repository를 자동 DataFetcher 등록할 수 있고, 자동 pagination은 **offset 기반, 기본 20개**입니다. 이 기능은 편리하지만 Persistence Model과 외부 Schema의 결합 및 pagination 정책 고착 가능성이 있으므로 Skeleton의 주류 API로 삼기보다 allowlist 기반 호환 기능으로 한정하는 것이 적절합니다. citeturn24view0 + + +## Schema 계약과 진화 정책 + +GraphQL Java는 programmatic schema와 SDL을 모두 제공하지만, 어느 방식을 선택해야 할지 확신이 없다면 SDL을 권장합니다. Spring Boot는 기본적으로 `src/main/resources/graphql/**`의 `.graphqls`, `.gqls`를 읽고, `classpath*:graphql/**/` 형태로 여러 모듈의 schema fragment도 조립할 수 있습니다. 따라서 **SDL First + module-owned fragment + platform-governed assembly**를 기본 계약으로 확정하는 것이 적절합니다. citeturn17search5turn19search1 + +```text +modules/order/src/main/resources/graphql/order/ +├── order-type.graphqls +├── order-query.graphqls +└── order-mutation.graphqls + +modules/graphql-schema/src/main/resources/graphql/common/ +├── scalar.graphqls +├── error.graphqls +├── connection.graphqls +└── directive.graphqls +``` + +플랫폼은 Schema 자체를 독점하지 않습니다. **도메인 모듈이 자기 Schema coordinate와 resolver를 소유하고**, `graphql-schema`는 이름 규칙, scalar, directive, schema assembly, validation, compatibility checker를 소유해야 합니다. + +### Type System 계약 + +**Output과 Input은 분리**합니다. + +```graphql +type Order { + id: ID! + status: OrderStatus! + createdAt: Instant! +} + +input CreateOrderInput { + customerId: ID! + items: [CreateOrderItemInput!]! +} +``` + +JPA Entity, Mongo Document 또는 provider SDK model을 Input/Output으로 재사용하지 않습니다. 이는 Persistence refactoring을 API breaking change로 만드는 것을 방지하고, 입력에 공개해서는 안 될 persistence field가 포함되는 것을 차단합니다. + +**Nullability는 단순 Java null annotation이 아니라 장애 격리 경계**로 취급해야 합니다. GraphQL에서 nullable이 기본이고 `!`가 Non-Null이며, Non-Null field가 실행 중 null이 되면 오류가 부모 Non-Null 경계를 따라 전파됩니다. 따라서 `String!`은 “평상시 DB에서 null이 아니다”라는 의미가 아니라 **정상 데이터, 권한 처리, dependency failure, resolver mapping을 포함해 이 field를 항상 제공할 수 있다**는 API 보장이어야 합니다. citeturn15search0 + +권장 규칙은 다음과 같습니다. + +| 상황 | 권장 | +|---|---| +| Aggregate identity | `ID!` | +| 반드시 존재하는 immutable value | Non-Null 후보 | +| 외부 서비스가 채우는 enrichment | Nullable 우선 | +| 권한에 따라 값을 반환하지 못할 수 있음 | Nullable 또는 별도 권한 모델 | +| Resolver가 부분 실패할 수 있는 expensive child | Nullable boundary | +| List 자체가 항상 존재 | `[T!]!` 후보 | +| List element 자체가 실패 가능 | `[T]` 또는 구조 재설계 | +| 신규 field | 기본 nullable로 도입 후 보장 검증 | +| Non-Null 전환 | 데이터 + resolver contract gate 필수 | + +GraphQL `ID`는 외부 API 관점에서 opaque identifier로 사용해야 하며, 데이터베이스 PK 형식을 GraphQL contract로 약속하지 않는 것이 좋습니다. DB가 `Long`, `UUID`, Mongo `ObjectId`여도 resolver mapper에서 external ID로 변환하면 됩니다. GraphQL 규격 자체도 ID를 식별자용 scalar로 정의하며 string 형태로 직렬화합니다. citeturn15search0 + +**September 2025 규격의 `@oneOf`**는 정확히 하나의 input field가 제공되고 그 값이 non-null이어야 하는 Input Object를 표현합니다. 각 구성 field 자체는 nullable이고 default value를 가질 수 없습니다. 따라서 상호 배타적인 여러 selector를 임의 validator보다 Schema 자체로 표현하는 데 적합합니다. citeturn15search0 + +```graphql +input OrderSelector @oneOf { + id: ID + orderNumber: String + externalReference: String +} +``` + +플랫폼에서는 `@oneOf`를 **Spec-Stable 기능**으로 분류하되 GraphQL Java/Spring 조합의 schema build 및 coercion contract test를 release gate에 포함하는 것이 좋습니다. + +**Scalar 정책**은 다음처럼 제한하는 것이 적절합니다. + +| Scalar | 판정 | 계약 | +|---|---|---| +| `ID` | Stable | opaque | +| `UUID` | Stable custom | canonical string | +| `Instant` | Stable custom | UTC timestamp | +| `Date` | Stable custom | calendar date | +| `BigDecimal` | Stable custom | 정확도 손실 없는 문자열/명시 coercion | +| `Long` | Stable custom | JS client 범위 고려 | +| `URL` | Advanced | parser/normalization 명시 | +| `Email` | Advanced | format validation과 ownership validation 분리 | +| `JSON` | Restricted | 명시 필드에만 allowlist | +| `Upload` | Unsupported | Fileserver 사용 | + +자유형 `JSON`은 typed GraphQL validation을 우회하므로 “모든 것을 넣는 escape hatch”로 제공해서는 안 됩니다. Custom scalar에는 명확한 serialization/coercion contract를 두고, 필요하면 GraphQL 규격의 `@specifiedBy`를 사용해 의미를 명시할 수 있습니다. citeturn15search0 + +Spring for GraphQL은 `graphql-multipart-request-spec`을 직접 지원하지 않으며 공식 문서도 GraphQL이 텍스트 데이터 교환을 중심으로 하고 별도의 비공식 multipart 규격이 존재한다고 설명합니다. 따라서 `Upload` scalar, multipart parser, binary streaming을 Core에 넣지 않고 기존 Fileserver에서 upload reservation/ticket을 발행하는 구조가 적합합니다. citeturn23view2 + +```text +GraphQL createFileUpload(...) + ↓ +Fileserver Application Use Case + ↓ +fileId + upload URL/ticket + +Binary +Client ──────────→ Fileserver/Object Storage + +GraphQL + ↓ +metadata / status / reference only +``` + +### Schema assembly과 startup 검증 + +Spring for GraphQL 2.0.4의 `SchemaMappingInspector`는 Schema field에 DataFetcher 또는 Java property mapping이 있는지, 존재하지 않는 Schema field에 DataFetcher가 등록됐는지, argument와 nullness가 Schema와 일치하는지 등을 startup에서 검사할 수 있습니다. 이를 단순 INFO report로 남기지 말고 Stable 플랫폼에서는 **CI 실패 또는 startup failure policy**로 승격하는 것이 좋습니다. citeturn18search3 + +Schema build gate는 최소한 다음 계약을 검사해야 합니다. + +```text +SDL parse +→ GraphQL schema validation +→ duplicate type / field / directive +→ scalar wiring +→ interface / union TypeResolver +→ resolver mapping inspection +→ argument mapping +→ nullability mapping +→ forbidden scalar/directive +→ schema compatibility +→ schema fingerprint +``` + +### Schema Evolution과 breaking-change 기준 + +September 2025 규격에서는 field뿐 아니라 argument, input field, enum value에도 `@deprecated`를 적용할 수 있습니다. 다만 default가 없는 required non-null argument/input field는 바로 deprecate할 수 없으며, 먼저 nullable로 만들거나 default를 부여해야 합니다. citeturn15search0 + +플랫폼 compatibility checker는 단순 “SDL diff”와 **wire compatibility**, **generated-client source compatibility**를 구분해야 합니다. + +| 변경 | Wire 판정 | Generated Client 위험 | 기본 정책 | +|---|---|---|---| +| nullable output field 추가 | 호환 | 낮음 | 허용 | +| non-null output field 추가 | 기존 operation에는 호환 | 생성 모델 변경 | 데이터 보장 검증 | +| field 삭제/rename | Breaking | 높음 | 금지 | +| output `T! → T` | Breaking | 높음 | 금지 | +| output `T → T!` | 대체로 강화 | source type 변경 가능 | Review | +| optional argument 추가 | 호환 | 낮음 | 허용 | +| required argument 추가 | Breaking | 높음 | 금지 | +| input `T → T!` | Breaking | 높음 | 금지 | +| input `T! → T` | 호환 방향 | 생성 모델 변경 | 허용+Review | +| optional input field 추가 | 호환 | 낮음 | 허용 | +| input field 삭제 | Breaking | 높음 | 금지 | +| enum value 삭제 | Breaking | 높음 | 금지 | +| output enum value 추가 | protocol additive | exhaustive switch 위험 | Client impact review | +| union/interface possible type 추가 | protocol additive | exhaustive codegen 위험 | Client impact review | +| scalar coercion 변경 | 사실상 Breaking | 높음 | 새 Scalar/version | +| directive 의미 변경 | Behavioral Breaking 가능 | 다양 | Review | +| deprecated element 유지 | 호환 | 경고 | Usage gate 적용 | + +삭제 정책은 다음과 같이 운영하는 편이 좋습니다. + +```text +@deprecated(reason: "Use ...") + ↓ +Schema Usage Observation + ↓ +Persisted Operation Reference Scan + ↓ +지원 종료 기간 + ↓ +Client owner 승인 + ↓ +Breaking Schema Release +``` + +Schema hash 하나만으로는 충분하지 않습니다. `GraphQlSchemaContract`에는 최소 `schemaHash`, `breakingPolicyVersion`, `scalarManifestVersion`, `directiveManifestVersion`을 포함시키는 것이 좋습니다. + + +## Transport와 실행·데이터 접근 계약 + +Spring for GraphQL의 핵심 실행 추상화는 `ExecutionGraphQlService`이고, HTTP·WebSocket 등 transport가 여기에 요청을 위임합니다. 이 구조를 플랫폼의 실제 내부 경계로 그대로 활용하면 transport policy와 GraphQL execution policy를 분리하기 좋습니다. citeturn18search0 + +권장 파이프라인은 다음입니다. + +```text +Transport Adapter + ↓ +Request Envelope Validation + ↓ +Authentication + ↓ +GraphQlRequestContext creation + ↓ +WebGraphQlInterceptor + ↓ +Persisted Operation lookup + ↓ +Parse / Validate + ↓ +OperationPolicy + ↓ +CostPolicy + ↓ +ExecutionGraphQlService + ↓ +Annotated Controller / DataFetcher + ↓ +Application Service + ↓ +Completion / Error Mapping +``` + +### Transport 지원 매트릭스 + +GraphQL over HTTP Draft는 POST를 MUST로 하고 GET을 MAY로 정의하지만, 현재 Spring for GraphQL의 server HTTP profile은 JSON body를 사용하는 POST를 기본 계약으로 합니다. Spring은 `application/graphql-response+json`에서 parse/validation failure에 4xx를 사용하고, validation을 통과해 execution이 시작된 뒤의 오류는 GraphQL `errors`와 HTTP 200으로 반환합니다. citeturn22view0turn23view1 + +| Transport | Spring 기능 | 등급 | 플랫폼 계약 | +|---|---|---|---| +| HTTP POST JSON | 기본 지원 | **Stable** | Query/Mutation | +| HTTP GET Query | HTTP Draft는 허용, Spring 기본 server profile과 차이 | Experimental | 초기 비지원 | +| `application/graphql-response+json` | 지원 | **Stable Preferred** | 새 client 기본 | +| legacy `application/json` response | 지원 | Stable Compat | 이전 client | +| WebSocket | `graphql-ws` 기반 | **Advanced Stable** | Subscription 중심 | +| SSE | Distinct Connection | Advanced | Subscription-only | +| RSocket | request-response/request-stream | Experimental | 내부 시스템 한정 | +| multipart upload | Spring 직접 미지원 | Unsupported | Fileserver | +| HTTP array batch | GraphQL core가 아님 | Unsupported | 필요 시 별도 Extension | + +Spring의 SSE 구현은 POST `application/json` + `Accept: text/event-stream`을 사용하고 **Distinct connections mode**만 구현하며, Query/Mutation이 아니라 Subscription의 대안으로 문서화되어 있습니다. WebSocket은 현재 `graphql-ws` 계열 protocol을 사용하고 과거 `subscriptions-transport-ws`는 inactive/superseded 상태입니다. RSocket에서는 Query/Mutation이 request-response, Subscription이 request-stream으로 처리됩니다. citeturn23view1turn23view3 + +HTTP Stable Profile은 다음처럼 명시하는 것이 좋습니다. + +```text +Method: + POST only + +Content-Type: + application/json + +Accept: + application/graphql-response+json preferred + application/json compatibility + +Body: + query + operationName + variables + extensions + +Policies: + requestBytes + variableBytes + extensions allowlist + named-operation requirement in production + CORS allowlist + CSRF profile according to credential mode + compression threshold + responseBytes + request timeout +``` + +GraphQL over HTTP Draft도 `query`, `operationName`, `variables`, `extensions`라는 요청 parameter를 정의하고, request media type은 `application/json`, response media type은 `application/graphql-response+json`으로 규정하고 있습니다. 다만 Stage 2 Draft이므로 `GraphQlHttpProfile.V1`처럼 플랫폼 Profile을 명시적으로 versioning하는 것이 중요합니다. citeturn22view0 + +### 실행 Profile + +Spring for GraphQL은 기본적으로 GraphQL Java의 비동기 실행 모델을 활용하며, reactive resolver는 `CompletionStage` 형태로 execution에 결합되고 Subscription에서는 `Publisher`가 유지됩니다. Java 21에서는 `@SchemaMapping`, `@BatchMapping` 등의 blocking `Callable`을 Virtual Thread executor에 보낼 수 있고, Spring Boot는 `spring.threads.virtual.enabled` 설정 시 annotated controller용 virtual-thread executor를 구성합니다. citeturn18search0turn18search2 + +권장 Profile은 세 가지입니다. + +| Profile | 적합한 workload | 규칙 | +|---|---|---| +| `BLOCKING_MVC` | JPA, blocking Mongo, blocking SDK | Java 21 VT 또는 bounded executor | +| `REACTIVE_WEBFLUX` | Reactive Mongo, WebClient, 높은 Subscription 수 | blocking 금지, Reactor Context | +| `MIXED_CONTROLLED` | 기존 blocking + 일부 reactive | 명시 adapter 필수, event-loop blocking 검출 | + +`MIXED`를 “아무 반환 타입이나 허용”이라는 의미로 사용해서는 안 됩니다. Resolver catalog에 `BLOCKING`, `ASYNC`, `REACTIVE`, `STREAM` 실행 유형을 등록하고 platform test가 WebFlux event loop에서 blocking repository가 호출되지 않는지 검증하는 편이 안전합니다. + +Spring은 GraphQL 전체 요청에 `TimeoutWebGraphQlInterceptor`를 제공하며 timeout 시 reactive data fetcher 쪽으로 cancellation 신호를 전달합니다. Streaming request에서는 stream이 성립될 때까지만 이 request timeout이 적용되고, 장기 Subscription에는 transport별 timeout을 별도로 구성해야 합니다. citeturn18search0turn18search4 + +따라서 timeout은 한 값이 아니라 다음 계층으로 나누어야 합니다. + +```text +transportHandshakeTimeout +requestExecutionTimeout +resolverBudget +databaseDeadline +httpClientDeadline +dataLoaderBatchTimeout +subscriptionIdleTimeout +subscriptionMaxAge +shutdownDrainTimeout +``` + +### Resolver와 Application Service 경계 + +Annotated controller는 Spring for GraphQL에서 Schema field와 DataFetcher를 연결하는 표준적 고수준 진입점입니다. `@QueryMapping`, `@MutationMapping`, `@SubscriptionMapping`, `@SchemaMapping`, `@BatchMapping`을 주류 API로 사용하고, raw `DataFetcher` 등록은 custom scalar/directive/federation 등 명시된 SPI로 제한하는 것이 적절합니다. citeturn18search2 + +```java +@Controller +final class OrderGraphQlController { + + private final FindOrderUseCase findOrder; + private final CreateOrderUseCase createOrder; + + @QueryMapping + OrderView order(@Argument String id, GraphQlRequestContext context) { + return findOrder.find(new FindOrderQuery(context.actor(), id)); + } + + @MutationMapping + CreateOrderPayload createOrder( + @Argument CreateOrderInput input, + GraphQlRequestContext context) { + + return createOrder.execute(input.toCommand(context.actor())); + } +} +``` + +```text +Allowed in Resolver +├─ GraphQL input coercion 이후 transport DTO 변환 +├─ Bean Validation +├─ Actor/Tenant/Locale context 전달 +├─ Application Use Case 호출 +└─ GraphQL DTO/Payload mapping + +Not Allowed +├─ EntityManager query +├─ MongoTemplate query +├─ HTTP retry/circuit breaker 구현 +├─ Kafka/Rabbit ACK 처리 +├─ ObjectStorage binary IO +├─ multi-step domain state transition +└─ provider exception을 그대로 client에 노출 +``` + +반환 기본형은 `DTO`, `ReadModel`, `Connection`, `MutationPayload`, `Publisher`이고 `Entity`, `Document`, provider SDK model, 자유형 `Map`은 금지 후보입니다. + +### Selection Set과 Fetch Profile + +Spring for GraphQL의 공식 Data Integration 문서는 Selection Set을 DB query로 직접 번역하는 gateway가 아니라고 명시합니다. 또한 DTO/interface projection과 Selection Set을 함께 사용할 수 있다고 설명합니다. citeturn24view0 + +따라서 다음 방식이 가장 안전합니다. + +```text +GraphQL Selection + ↓ +SelectionClassifier + ↓ +registered FetchProfile + ↓ +Application Query + ↓ +JPA/Mongo Repository +``` + +예를 들면: + +```text +Order.BASIC + id status createdAt + +Order.WITH_ITEMS + BASIC + items + +Order.WITH_CUSTOMER + BASIC + customer + +Order.FULL_DETAIL + BASIC + items + customer + paymentSummary +``` + +GraphQL 플랫폼은 `DataFetchingFieldSelectionSet`을 보고 **유한 집합의 Fetch Profile 중 하나를 선택**할 수 있지만, 실제 SQL join fetch, EntityGraph, DTO projection, Mongo aggregation은 JPA/Mongo 모듈에 남겨둡니다. + +이 구조는 다음 실패를 동시에 막습니다. + +```text +Selection 조합마다 SQL plan 생성 +필드 추가가 즉시 DB column exposure로 연결 +computed field를 DB column으로 오인 +권한 field를 projection에 잘못 포함 +association lazy access로 N+1 발생 +Mongo/JPA의 서로 다른 fetch semantics를 GraphQL이 흡수 +``` + +`@GraphQlRepository` 자동 노출은 이 원칙을 우회할 수 있으므로 `graphql-spring-data-compat`에서 등록 가능한 repository와 argument/filter를 명시적으로 allowlist해야 합니다. Spring의 자동 등록 기능 자체가 GraphQL arguments를 Querydsl predicate나 QBE로 바꾸고 offset pagination까지 수행하기 때문에, 플랫폼의 기본 추상화로 사용하면 저장소 세부가 Schema 계약 쪽으로 빠르게 올라옵니다. citeturn24view0 + + +## DataLoader·Pagination·Mutation·Error 계약 + +### DataLoader는 요청 단위 Batch Planner + +GraphQL Java/Spring의 DataLoader는 Graph 탐색 중 발생하는 반복 fetch를 모아 batch load하고, 요청 범위 cache를 이용해 동일 key의 중복 load를 줄이는 수단입니다. Spring for GraphQL은 `BatchLoaderRegistry`와 `@BatchMapping`을 제공하고, DataLoader cache는 요청 내부에서 동작합니다. citeturn14search5turn18search2 + +따라서 다음 규칙을 Stable contract로 삼는 것이 좋습니다. + +```text +DataLoader instance +→ 반드시 request scoped + +DataLoader cache +→ 한 GraphQL execution 내부만 + +Cross-request caching +→ Redis / Application Cache responsibility + +Authorization +→ BatchLoader도 동일 Actor/Tenant Context 사용 + +Batch I/O +→ JPA IN / Mongo $in / HTTP batch API 한도에 맞춰 chunk + +Result +→ ordered loader면 key와 동일 순서 +→ mapped loader면 key 기반 명시 대응 +``` + +요청 간 DataLoader singleton을 공유하지 않는 것이 특히 중요합니다. 사용자·tenant별 데이터가 cache에 남는 경우 교차 사용자 데이터 유출까지 이어질 수 있기 때문입니다. GraphQL Java의 DataLoader 문서도 사용자별 데이터를 다루는 경우 per-request DataLoader 사용을 권장합니다. citeturn10search3 + +Batch 계약은 다음 데이터를 보존해야 합니다. + +```java +record GraphQlBatchPolicy( + String loaderName, + int maxBatchSize, + Duration timeout, + MissingKeyPolicy missingKeyPolicy, + BatchErrorPolicy errorPolicy +) {} +``` + +`@BatchMapping`에서 ordered collection을 반환할 경우 source/parent와 같은 순서여야 하고, `Map` 반환형을 이용하면 key별 대응을 명시할 수 있습니다. citeturn18search2 + +GraphQL Java 25에는 chained DataLoader의 자동 dispatch 기능이 추가됐지만 opt-in이고 dispatch ordering을 변화시킬 수 있으므로 **G2 Advanced + 별도 회귀 테스트**가 적절합니다. 기존 N+1 해결만을 위해 Stable 기본값으로 켜지 않는 것이 좋습니다. citeturn10search3 + +DataLoader와 저장소 기술의 선택 기준은 다음과 같습니다. + +| 문제 | 우선 수단 | +|---|---| +| 동일 parent type의 child ID 반복 조회 | DataLoader | +| Root query 자체가 과도한 Entity graph 적재 | DTO Projection / Fetch Profile | +| JPA 단일 aggregate에서 반드시 함께 읽음 | JPA fetch plan | +| Mongo 내 `$lookup`이 본질적으로 적합 | Mongo Aggregation | +| Downstream이 batch API 제공 | HTTP batch DataLoader | +| Downstream이 batch API 없음 | application aggregator + concurrency/bulkhead | +| unbounded child collection | DataLoader가 아니라 Pagination | + +### Cursor Connection + +Spring for GraphQL 2.0.4는 Connection/Edge/PageInfo 패턴과 `first`, `after`, `last`, `before` 입력을 지원하며 Spring Data `Window`와 `Slice`를 Connection으로 adapter할 수 있습니다. 또한 keyset cursor를 JSON으로 직렬화하고 Base64 encoding하는 전략도 제공합니다. citeturn24view0 + +다만 **Base64는 encoding이지 무결성 보호가 아닙니다.** Backend Skeleton에서 cursor를 security-sensitive server state token으로 취급한다면 다음 envelope를 권장합니다. + +```json +{ + "v": 1, + "profile": "orders-by-created-at", + "direction": "FORWARD", + "keyset": { + "createdAt": "2026-08-12T01:00:00Z", + "id": "..." + }, + "filter": "sha256:...", + "kid": "cursor-key-2026-01", + "mac": "..." +} +``` + +Cursor 계약: + +```text +Opaque to client +Versioned +Query profile bound +Filter fingerprint bound +Sort/tie-breaker included +HMAC authenticated +Unknown version rejected +Page size revalidated server-side +Sensitive raw values 최소화 +``` + +저장소별 실제 pagination은 GraphQL이 아닌 storage/application layer가 소유합니다. + +```text +JPA +→ keyset / ScrollPosition + +MongoDB +→ range predicate + _id tie-breaker + +External API +→ upstream opaque cursor를 signed envelope 안에 보관 +``` + +`totalCount`는 기본 Connection field로 강제하지 않는 편이 좋습니다. 큰 relation에서는 page fetch보다 count가 더 비쌀 수 있기 때문에, 필요한 Connection profile에만 explicit resolver로 제공합니다. + +### Mutation과 Transaction + +GraphQL 규격은 Mutation root field를 문서 순서대로 serial execution하지만, **그 순차성이 데이터베이스 transaction을 의미하지는 않습니다**. Spring for GraphQL 2.0.4의 현재 Data Integration 문서도 GraphQL 자체에 transaction semantics가 없다고 명시하고, transaction-per-controller-method를 가장 단순한 권장 방식으로 설명합니다. 여러 DataFetcher 전체에 request-wide transaction을 걸려면 execution을 serial하게 만드는 등 훨씬 큰 제약이 필요합니다. citeturn15search0turn24view0 + +따라서 플랫폼 기본 계약은 다음으로 확정하는 것이 좋습니다. + +```text +Mutation Resolver + ↓ +one Application Use Case + ↓ +one explicit transaction boundary +``` + +더 구체적으로는 transaction annotation 자체도 Resolver보다 Application Service에 두는 것이 아키텍처 경계를 더 잘 유지합니다. + +```java +@Service +final class UpdateOrderService implements UpdateOrderUseCase { + + @Transactional + public UpdateOrderResult execute(UpdateOrderCommand command) { + // domain operation + } +} +``` + +```text +mutation { + updateOrder(...) + createInvoice(...) +} +``` + +위 두 root field는 **순차 실행될 뿐 독립 Use Case Transaction**입니다. 두 작업이 반드시 atomic해야 한다면 client가 root mutation 두 개를 조합하도록 두지 말고: + +```graphql +type Mutation { + confirmOrderAndCreateInvoice( + input: ConfirmOrderInput! + ): ConfirmOrderPayload! +} +``` + +처럼 **하나의 Application Use Case를 표현하는 Mutation**을 제공하는 것이 맞습니다. Spring 문서 역시 여러 변경을 하나의 transaction으로 유지해야 한다면 필요한 모든 input을 하나의 mutation method가 받도록 설계하는 방식을 권고합니다. citeturn24view0 + +Optimistic concurrency도 GraphQL 자체 기능이 아니라 Use Case contract로 전달합니다. + +```graphql +input UpdateOrderInput { + orderId: ID! + expectedVersion: Long! + status: OrderStatus! +} +``` + +Idempotency 역시 플랫폼 Extension입니다. 권장 범위는 HTTP request 전체가 아니라 **side-effecting Mutation Use Case**입니다. + +```text +idempotencyKey ++ actor/client identity ++ mutation coordinate ++ normalized business input hash +→ Idempotency Record +``` + +HTTP `Idempotency-Key`를 수용하더라도 이를 Mutation Context로 변환해 Application Use Case의 idempotency mechanism에 전달해야 하며, GraphQL transport 자체가 DB replay policy를 구현해서는 안 됩니다. + +### Error contract + +GraphQL의 중요한 장점은 실행 중 field error가 발생해도 가능한 data를 함께 반환할 수 있다는 점입니다. Non-Null field error는 부모 경계로 전파될 수 있으므로 Error model과 Nullability policy는 함께 설계해야 합니다. citeturn15search0 + +권장 네 계층은 다음과 같습니다. + +| 계층 | 발생 시점 | `path` | HTTP Stable Profile | 표현 | +|---|---|---:|---|---| +| `REQUEST_ERROR` | parse/validation/coercion | 보통 없음 | 4xx with new media type | `errors` | +| `FIELD_ERROR` | resolver execution | 있음 | 200 | partial `data` + `errors` | +| `BUSINESS_RESULT` | 예상 가능한 업무 결과 | 보통 data | 200 | typed payload/union 우선 | +| `INTERNAL_ERROR` | 예상 밖 장애 | 있음/없음 | 실행 후면 200 | opaque `errors` | + +Spring for GraphQL에서 unresolved DataFetcher exception은 기본적으로 `INTERNAL_ERROR`와 `executionId`가 들어간 의도적으로 불투명한 message로 바뀌며, request execution 전에 발생한 global parse/validation error는 `DataFetcherExceptionResolver`가 처리할 수 없습니다. Subscription publisher의 사후 오류에는 별도 `SubscriptionExceptionResolver`가 있습니다. citeturn18search0turn23view0 + +권장 Wire Error는 다음과 같습니다. + +```json +{ + "message": "요청을 처리할 수 없습니다.", + "path": ["order", "payment"], + "extensions": { + "code": "PAYMENT_DEPENDENCY_UNAVAILABLE", + "category": "DEPENDENCY", + "retryable": true, + "executionId": "..." + } +} +``` + +공개 가능한 `extensions`는 allowlist로 고정해야 합니다. + +```text +code +category +retryable +executionId +constraint +field // safe logical input field only +``` + +다음은 공개 금지입니다. + +```text +Java exception class +stack trace +SQL +JPQL +Mongo query +HTTP downstream URL with credentials +provider error body +database identifier +tenant id raw value +access token +internal host +``` + +예상 가능한 업무 상태는 가능하면 GraphQL errors보다 typed data로 표현하는 방식을 병행할 수 있습니다. + +```graphql +union CreateOrderResult = + CreateOrderSuccess + | OrderAlreadyExists + | InvalidOrderState +``` + +단, 모든 validation을 union으로 바꾸는 것도 바람직하지 않습니다. **Schema/input coercion 문제는 request error, 예상 가능한 업무 분기는 typed result, 예상 밖 실행 실패는 GraphQL error**라는 기준이 가장 일관됩니다. + + +## 보안·비용 통제·Persisted Operation + +GraphQL endpoint는 URL 하나를 공유하므로 HTTP URL security만으로 operation이나 field별 접근권한을 구분할 수 없습니다. Spring for GraphQL도 이 점을 명시하며 서비스나 data-fetching 계층에서 `@PreAuthorize`, `@Secured` 같은 fine-grained security를 적용하도록 안내합니다. citeturn18search1 + +보안 모델은 다음 계층으로 분리해야 합니다. + +```text +Transport Authentication + ↓ +Client Profile Authorization + ↓ +Operation Authorization + ↓ +Field / Use Case Authorization + ↓ +Object Authorization + ↓ +Tenant Isolation +``` + +**Schema visibility와 authorization은 별개**입니다. GraphQL Java의 `GraphqlFieldVisibility`는 Schema에서 특정 field를 보이지 않게 할 수 있지만, object instance에 대해 사용자가 실제로 접근해도 되는지를 검증하는 수단은 아닙니다. 따라서 “introspection에서 숨겼다 = 보호됐다”라는 설계를 금지해야 합니다. citeturn17search18turn18search1 + +권장 request context는 transport DTO와 domain context를 뒤섞지 않고 immutable하게 구성합니다. + +```java +record GraphQlRequestContext( + Actor actor, + TenantContext tenant, + ClientProfile clientProfile, + Locale locale, + String operationId, + String traceId, + Deadline deadline +) {} +``` + +`tenantId`를 GraphQL argument에서 받아 신뢰하지 않고 authentication/session으로 결정된 `TenantContext`를 Application Service와 BatchLoader까지 전달해야 합니다. + +### Introspection·GraphiQL + +Spring Boot에서 Schema introspection은 기본 허용이며 설정으로 비활성화할 수 있고, GraphiQL은 기본 비활성입니다. citeturn19search1 + +권장 운영 정책은 다음과 같습니다. + +| 환경 | Introspection | GraphiQL | Schema Printer | +|---|---|---|---| +| Local | Allow | Allow | Allow | +| Test | Allow | Optional | CI only | +| Dev | Authenticated | Authenticated | Admin | +| Staging | Admin/CI profile | Off | Admin | +| Prod internal | Client profile 기반 | Off | G4 Admin | +| Prod public | 제한 또는 Off | Off | Off | + +Introspection을 꺼도 resolver authorization이나 cost defense가 대체되지 않습니다. 실제 방어는 Field/Object Authorization, Operation Policy, Cost Limit, request size limit, Persisted Operation 등에서 이루어져야 합니다. + +### Operation cost와 DoS 방어 + +GraphQL Java 25에는 parser 수준의 query character/token/whitespace/grammar-depth 제한과 query depth/complexity instrumentation이 있습니다. 공식 문서의 library 기본 ceiling은 query characters 약 1 MiB, token 15,000, whitespace token 200,000, grammar rule depth 500 등으로 상당히 넓으므로, 이를 그대로 public API의 business limit로 사용하는 대신 플랫폼에서 더 작은 profile을 별도로 두는 것이 좋습니다. citeturn10search1turn10search2 + +방어는 “Depth 한 개”가 아니라 다음 순서가 적절합니다. + +```text +HTTP body bytes + ↓ +variables bytes + ↓ +persisted-operation lookup / request format + ↓ +parser character/token/grammar limits + ↓ +GraphQL parse + ↓ +GraphQL validation + ↓ +operation count/name/type + ↓ +introspection policy + ↓ +selection depth + ↓ +field/alias/fragment count + ↓ +input list/string limits + ↓ +complexity + ↓ +estimated response node budget + ↓ +execution timeout + ↓ +runtime response-byte limit +``` + +Complexity는 단순 field count보다 **cardinality와 resolver 특성**을 포함해야 합니다. + +```text +FieldCost = + baseFieldCost + + resolverWeight + + childCost × effectiveCardinality +``` + +예를 들면 다음처럼 catalog를 관리할 수 있습니다. + +| Resolver class | 상대 weight 예 | +|---|---:| +| in-memory scalar/property | 1 | +| indexed DB lookup | 2 | +| batched relation | 3 | +| bounded aggregation | 8 | +| external service batch | 10 | +| external per-object request | 20 | +| search/heavy aggregation | 별도 승인 | + +위 숫자는 표준값이 아니라 **초기 calibration용 상대 weight**입니다. Release benchmark에서 실제 latency, DB statement 수, examined-row/document 수, downstream call 수와 비교해 조정해야 합니다. + +특히 List/Connection은 다음처럼 계산해야 합니다. + +```text +requested first = 50 +child subtree cost = 10 + +connection cost +≈ root cost + 50 × child cost +``` + +Client가 `first`를 생략했다고 비용을 1로 계산해서는 안 되고 `defaultPageSize`를 사용해야 하며, `first > maxPageSize`는 execution 전에 거부해야 합니다. + +Cost Profile은 다음처럼 구성할 수 있습니다. + +```java +record GraphQlClientPolicy( + int maxDocumentBytes, + int maxVariablesBytes, + int maxDepth, + int maxFields, + int maxAliases, + int maxFragments, + int maxInputListElements, + int maxPageSize, + long maxComplexity, + long maxResponseNodes, + Duration maxExecutionTime, + boolean introspectionAllowed, + boolean persistedOperationOnly +) {} +``` + +```text +PUBLIC +PARTNER +FIRST_PARTY +ADMIN +INTROSPECTION +``` + +각 실제 숫자는 부하 시험을 통과한 **환경별 manifest**에 두고 애플리케이션 코드에 산재시키지 않는 것이 좋습니다. + +### Operation name + +GraphQL 규격상 단일 anonymous operation은 유효할 수 있지만, 운영 환경에서 anonymous operation은 tracing, cost exception, persisted registry, usage 분석을 어렵게 합니다. 따라서 다음 정책이 실용적입니다. + +```text +Local +→ anonymous 허용 + +Dev/Test +→ 경고 + +Production FIRST_PARTY/PARTNER +→ named operation 필수 + +PUBLIC +→ named operation 또는 persisted-only +``` + +### Preparsed cache와 Persisted Operation 분리 + +GraphQL Java의 `PreparsedDocumentProvider`는 parsing/validation 결과인 Document를 재사용할 뿐 **실행 결과를 cache하지 않습니다**. 공식 문서도 이 점을 명확히 구분합니다. citeturn16search4 + +따라서 플랫폼의 세 cache를 절대 합치지 않아야 합니다. + +| 기능 | Cache 대상 | 권한 민감도 | 기본 | +|---|---|---|---| +| Preparsed Document Cache | parse/validation 결과 | schema/profile 의존 | Enabled bounded | +| Persisted Operation Registry | 승인된 operation text | client/profile 의존 | Advanced Stable | +| Response Cache | 실행 결과 | actor/tenant/permission 매우 민감 | Disabled | + +Preparsed key는 raw query 하나만으로 끝내기보다 적어도 다음을 고려해야 합니다. + +```text +documentHash +schemaContractHash +validationPolicyVersion +clientSchemaProfile +``` + +Field visibility나 validation rule이 client profile별로 달라지는데 query 문자열만 cache key로 사용하면 잘못 검증된 Document를 재사용할 가능성이 있기 때문입니다. + +Persisted Operation registry는 다음 형태가 적절합니다. + +```text +PersistedOperation +├─ operationId +├─ operationName +├─ sha256Document +├─ canonicalDocument +├─ schemaContractHash +├─ allowedClientProfiles +├─ maximumComplexity +├─ maximumVariablesBytes +├─ status: ACTIVE | DEPRECATED | BLOCKED +├─ registeredAt +└─ expiresAt? +``` + +실행은 다음처럼 합니다. + +```text +operationId + ↓ +registry lookup + ↓ +client allowlist + ↓ +document hash / schema compatibility + ↓ +variable validation + ↓ +cost policy + ↓ +execute +``` + +이렇게 하면 incident 시 특정 operation만 G4 Admin Plane에서 `BLOCKED` 처리할 수 있습니다. + +### WebSocket 인증과 장기 권한 + +Spring for GraphQL에는 WebSocket `connection_init` payload에서 인증 정보를 꺼내 인증한 후 SecurityContext를 이후 request로 전파하는 interceptor가 있습니다. citeturn18search12 + +그러나 장기 Subscription에서 인증은 connection 시점 한 번으로 끝내면 안 됩니다. Stable 정책 후보는 **credential expiry 시 connection 종료**입니다. + +```text +connection_init +→ authenticate +→ capture actor / tenant / credential expiry +→ connection_ack +→ subscribe +→ initial authorization +→ events +→ token expiry or revocation signal +→ complete/close +``` + +resource ownership이 수시로 바뀌는 매우 민감한 Subscription이라면 각 event를 Integration Event에서 GraphQL DTO로 바꾸기 전에 Application Authorization Service에 재검증하도록 별도 Profile을 둡니다. + + +## Subscription·Federation·Codegen·관측성 + +GraphQL Specification은 Subscription을 source event stream으로부터 response stream을 만드는 장기 operation으로 정의하지만, **transport protocol, ACK, buffering, replay, resend, QoS는 정의하지 않습니다**. 따라서 GraphQL Subscription을 Kafka/RabbitMQ 등의 durable messaging으로 간주할 수 없습니다. citeturn6search0turn22view0 + +경계는 다음과 같습니다. + +```text +Messaging Platform +├─ persistence +├─ offset +├─ ACK +├─ replay +├─ retry +└─ DLQ + +GraphQL Subscription +├─ client selection +├─ connected actor context +├─ authorization +├─ event → GraphQL DTO +├─ live delivery +└─ cancellation +``` + +권장 source 구조는 다음입니다. + +```text +Kafka / Rabbit / Application Publisher + ↓ + Subscription Source Adapter + ↓ + authorization / filtering + ↓ + Publisher + ↓ +GraphQL selection completion + ↓ +WebSocket or SSE +``` + +외부 Integration Event를 그대로 GraphQL type으로 반환하지 말고 stable GraphQL DTO로 변환해야 합니다. Messaging schema와 GraphQL schema의 lifecycle이 달라야 하기 때문입니다. + +### Subscription 상태 모델 + +```text +CONNECTING +→ AUTHENTICATING +→ READY +→ SUBSCRIBED +→ STREAMING +→ CANCELLING +→ COMPLETED + +Exceptional: +AUTH_EXPIRED +SLOW_CONSUMER +SOURCE_FAILED +SERVER_DRAINING +PROTOCOL_ERROR +``` + +관리할 connection policy: + +```text +connectionInitTimeout +heartbeat/ping-pong +idleTimeout +maxConnectionAge +maxSubscriptionsPerConnection +maxBufferedEvents +slowConsumerPolicy +subscriptionAuthPolicy +shutdownDrain +sourceCancellation +``` + +Spring WebFlux WebSocket handler는 non-blocking I/O와 backpressure를 사용하며 Subscription은 Reactive Streams `Publisher`로 처리됩니다. 따라서 수천~수만 장기 connection을 주요 목표로 한다면 Subscription transport는 `REACTIVE_WEBFLUX`가 기본 release lane이 되는 것이 타당합니다. citeturn23view1 + +기본 slow-consumer 정책은 **무음 event drop보다 connection 종료**를 권장합니다. GraphQL 자체에는 replay 표준이 없기 때문에 drop하면 client가 어느 event를 잃었는지 알 수 없습니다. Event loss가 업무상 허용되는 telemetry 성격의 기능만 별도 `LOW_LATENCY_DROP_ALLOWED` profile을 사용할 수 있습니다. + +### Event ordering + +Spring for GraphQL 2.0.4 문서는 nested asynchronous field fetch 때문에 Subscription item이 source 순서와 다르게 완료될 수 있음을 설명하며, `SubscriptionExecutionStrategy.KEEP_SUBSCRIPTION_EVENTS_ORDERED` flag로 buffering해 source ordering을 유지할 수 있습니다. citeturn23view0 + +따라서 두 Profile을 분리하는 것이 좋습니다. + +| Profile | 보장 | 대가 | +|---|---|---| +| `LOW_LATENCY` | 완료되는 즉시 전달 | event 순서 변화 가능 | +| `ORDERED` | upstream ordering 보존 | head-of-line blocking/버퍼 증가 | + +### Replay Extension + +GraphQL 표준 자체에는 resume token이 없으므로 다음 기능을 GraphQL Stable Core의 보장으로 광고하지 않습니다. citeturn6search0 + +내구성 있는 재연결이 실제 요구라면 G2/G3 Extension으로: + +```text +snapshotSequence +eventSequence +messagingOffset +subscriptionCursor +snapshot + live handoff +``` + +를 정의하고, 실제 replay guarantee는 Messaging Platform이 소유하도록 해야 합니다. + +### SSE와 WebSocket + +Spring SSE는 Distinct Connection mode이므로 Subscription 한 개당 HTTP streaming connection이라는 운영적 특성을 고려해야 합니다. HTTP/2가 connection 비용을 줄일 수 있지만 WebSocket multiplexing과 운영 특성이 다릅니다. citeturn23view1 + +권장 선택은: + +```text +브라우저 양방향 프로토콜·여러 subscription multiplexing +→ WebSocket + +단순 server→client stream, proxy 친화성 중요 +→ SSE Advanced + +Spring 내부 ecosystem의 RSocket 요구 +→ RSocket Experimental +``` + +### Federation + +Spring for GraphQL 2.0.4는 `federation-jvm` 통합과 `FederationSchemaFactory`, `@EntityMapping`을 제공하고 federated entity batch loading도 지원합니다. citeturn17search1 + +그러나 **Federation 지원 가능 = Skeleton 기본 architecture여야 함**은 아닙니다. + +권장 등급: + +```text +Single executable schema +→ Stable Default + +Federation Subgraph +→ Advanced Optional + +Federation Router/Supergraph 운영 +→ 별도 프로젝트 또는 Experimental + +Schema Stitching +→ Core 비지원 +``` + +Federation activation gate는 다음 조건을 요구하는 것이 좋습니다. + +```text +독립 배포되는 서비스 ++ schema ownership이 실제로 팀별 분리 ++ composition CI ++ router 운영 owner ++ distributed trace ++ cross-subgraph latency budget ++ entity key lifecycle policy ++ partial-failure policy +``` + +그렇지 않으면 Federation은 단순 Schema 분리보다 cross-subgraph N+1, network amplification, 배포 순서, entity key 변경, 권한 중복 같은 훨씬 큰 운영 비용을 가져옵니다. + +### Code Generation + +Spring for GraphQL 2.0.4의 Code Generation 문서는 DGS Codegen을 통해 client request/input/response selection types와 Schema data type을 생성할 수 있다고 설명하면서, **애플리케이션 자체의 data type은 로직을 넣어야 할 경우 code generation이 이상적이지 않을 수 있고 client type은 좋은 후보**라고 명시합니다. citeturn17search0 + +따라서 정책은 다음처럼 명확합니다. + +```text +Schema validation +→ 적극 사용 + +Operation validation +→ 적극 사용 + +Client request/response model generation +→ 지원 + +Transport-only DTO generation +→ 선택 지원 + +Domain Entity generation +→ 금지 + +Application Use Case interface generation +→ 금지 + +Repository generation from GraphQL schema +→ 금지 +``` + +즉: + +```text +SDL + ↓ +Generated GraphQL Transport Types (optional) + ↓ +Mapper + ↓ +Human-owned Application DTO / Use Case +``` + +를 유지합니다. + +### 관측성 + +Spring for GraphQL은 Micrometer 기반으로 `graphql.request`, non-trivial `graphql.datafetcher`, `graphql.dataloader` observation을 제공합니다. DataFetcher에는 field name/outcome/error type 등이, DataLoader에는 loader name/outcome/size 등이 관측 정보로 제공됩니다. citeturn14search2 + +플랫폼은 Spring observation을 재구현하지 말고 **명명 규칙과 cardinality policy를 추가**해야 합니다. + +```text +Request +graphql.request +├─ operationName +├─ operationType +├─ clientProfile +├─ persisted +├─ outcome +├─ errorCategory +├─ complexityBucket +├─ depthBucket +├─ duration +└─ responseBytes + +Resolver +graphql.datafetcher +├─ schemaCoordinate +├─ resolverCatalog +├─ outcome +└─ duration + +DataLoader +graphql.dataloader +├─ loaderName +├─ batchSize +├─ requestedKeys +├─ cacheEffect +└─ duration +``` + +저 cardinality catalog에 등록된 `operationName`, `schemaCoordinate`, `loaderName`은 사용할 수 있지만 raw query와 argument를 metric label로 사용해서는 안 됩니다. + +```text +Metric/Trace에 금지 +├─ raw GraphQL document +├─ variables +├─ userId +├─ raw tenantId +├─ object ID +├─ cursor +├─ Authorization header +├─ connection_init token +└─ arbitrary full field path +``` + +Trace에는 다음 구간이 연결되어야 합니다. + +```text +HTTP/WebSocket receive +→ GraphQL request +→ resolver +→ DataLoader +→ Application Use Case +→ DB/HTTP/Messaging +``` + +GraphQL Java v25의 Profiler는 DataFetcher, DataLoader와 execution timing 분석 기능을 제공하므로 Local/Dev 성능 분석이나 G4 Admin diagnostic에 유용하지만, public GraphQL response의 일반 extension으로 노출하지 않는 편이 적절합니다. citeturn17search15 + + +## 테스트·지원 등급·구현 순서와 Release Gate + +Spring의 `GraphQlTester`는 transport-independent 테스트 workflow를 제공하고 `HttpGraphQlTester`, `WebSocketGraphQlTester`, `RSocketGraphQlTester`, `ExecutionGraphQlServiceTester`, `WebGraphQlTester` 등의 변형을 제공합니다. 따라서 같은 operation document를 execution-level과 실제 transport-level에서 반복 검증하는 구조를 만들 수 있습니다. citeturn14search8 + +### 지원 범위 최종안 + +| Capability | 등급 | Release 조건 | +|---|---|---| +| SDL-first schema | **Stable** | schema/build compatibility gate | +| Query/Mutation annotated resolver | **Stable** | application boundary test | +| HTTP POST | **Stable** | media/status contract | +| `application/graphql-response+json` | **Stable preferred** | client compatibility | +| Partial data/error | **Stable** | null propagation test | +| Request context/security | **Stable** | actor/tenant isolation | +| DataLoader/BatchMapping | **Stable** | N+1/batch isolation | +| Cursor Connection | **Stable** | signed cursor/keyset tests | +| Cost control | **Stable** | attack + load tests | +| Preparsed cache | **Stable** | bounded/cache-key test | +| Persisted Operation | **Advanced Stable** | registry/admin tooling | +| Selection→registered Fetch Profile | **Advanced Stable** | plan/query regression | +| Virtual-thread MVC | **Stable Profile** | blocking workload load test | +| Reactive WebFlux | **Stable Profile** | BlockHound/equivalent gate | +| WebSocket Subscription | **Advanced Stable** | soak/reconnect/auth/cancel | +| SSE Subscription | **Advanced** | connection scalability | +| DataLoader chaining v25 | **Advanced** | dispatch regression | +| Federation Subgraph | **Advanced** | composition/trace/failure gate | +| Client Codegen | **Optional Stable tooling** | generated-source compatibility | +| Server transport DTO Codegen | Advanced | mapping policy | +| RSocket | **Experimental** | explicit consumer | +| HTTP GET | **Experimental** | HTTP draft compatibility | +| Federation Router | **Experimental/외부** | independent ops | +| Incremental delivery | **Experimental/초기 비지원** | spec/framework profile 확정 후 | +| Response Cache | **초기 비지원** | actor/tenant/cache-key 모델 확정 전 | +| Multipart Upload | **Unsupported** | Fileserver 사용 | +| HTTP array batch | **Unsupported** | 별도 extension 없이는 금지 | +| Entity/Document 자동 API 노출 | **Unsupported as default** | compat allowlist만 | +| GraphQL request-wide DB TX | **Unsupported as default** | 특수 instrumentation만 | + +### 계약 테스트 매트릭스 + +| 영역 | 반드시 검증할 계약 | +|---|---| +| Schema | SDL parse, duplicate, scalar, interface/union, mapping inspection, golden snapshot | +| Compatibility | field/arg/input/nullability/enum/union/directive diff | +| HTTP | media type, malformed JSON, parse/validation 4xx, execution error 200 | +| Resolver | DTO return, direct repository ban, context propagation | +| Query | variables, fragment, alias, directive, partial data | +| DataLoader | query count, duplicate key, missing key, ordering, cache scope, tenant isolation | +| Pagination | forward/backward, tie value, concurrent insert/delete, cursor HMAC, version | +| Mutation | transaction, second root failure, optimistic conflict, idempotency | +| Error | request/field/business/internal, null propagation, masking | +| Security | unauthenticated, field/object/tenant, introspection, WS auth | +| Cost | chars, token, depth, alias bomb, fragment bomb, nested list, variable size | +| Persisted | hash mismatch, schema mismatch, client deny, blocked operation | +| Subscription | connect, cancel, auth expiry, ordering, slow consumer, source failure | +| Federation | composition, entity key, batch entity load, partial subgraph failure | +| Observability | no raw query/PII tag, trace correlation, cardinality | +| Shutdown | in-flight query, WS drain, cancellation propagation | + +### 성능·장애 Release Gate + +Stable 선언 전에 다음 benchmark는 실제 storage/integration test 환경과 결합해야 합니다. + +```text +Named query high concurrency +Deep-but-valid query +Wide alias query +Nested Connection +DataLoader batch saturation +JPA connection pool saturation +Mongo pool saturation +HTTP downstream bulkhead saturation +Dependency timeout +Partial dependency failure +Large response serialization +Virtual thread saturation +Event-loop blocking detection +``` + +Subscription lane은 별도로: + +```text +1k persistent connections +→ 10k target connection test +→ multiple subscriptions per connection +→ event burst +→ slow consumer +→ cancel storm +→ auth expiry +→ server restart +→ rolling deployment +→ graceful drain +→ source restart +``` + +Spring WebSocket의 reactive path가 backpressure를 제공한다고 하더라도 **GraphQL 플랫폼의 bounded buffer와 downstream event source가 자동으로 안전해지는 것은 아니므로**, 실제 slow consumer 시 memory profile과 cancellation propagation을 계측해야 합니다. citeturn23view1 + +### 실무 실패 사례와 설계 규칙 + +| 실패 상황 | 직접 원인 | 플랫폼 규칙 | 회귀 테스트 | +|---|---|---|---| +| JPA Entity 직접 GraphQL 반환 | Persistence/API 결합, lazy access | DTO/ReadModel only | persistence association access 검사 | +| Field마다 repository query | N+1 | BatchMapping/DataLoader | DB query count | +| 전역 DataLoader | request 간 cache leakage | per-request loader | actor/tenant cross-request test | +| unbounded child list | response 폭증 | Connection + maxPage | complexity/node budget | +| depth만 제한 | wide alias attack | depth + fields + alias + complexity | alias bomb | +| selection→SQL 직접 생성 | plan 조합 폭증 | finite FetchProfile | query-plan snapshot | +| root mutations를 하나의 TX로 오인 | serial ≠ atomic | Use Case TX | second mutation failure | +| 모든 GraphQL error를 HTTP 500 | partial-data semantics 훼손 | request/field error 분리 | partial response | +| 내부 exception message 노출 | 정보 유출 | opaque INTERNAL_ERROR | SQL/stack leak | +| 자유형 JSON 입력 | Schema validation 우회 | typed input | unexpected-field/injection | +| `@GraphQlRepository` 무제한 사용 | persistence exposure | compat allowlist | forbidden filter/sort | +| multipart upload | fileserver 기능 중복 | upload ticket pattern | Upload scalar absence | +| Subscription을 durable queue로 간주 | replay/ACK 표준 없음 | Messaging + live adapter | disconnect loss | +| WS init 시점만 auth | 장기 권한 회수 미반영 | expiry/revalidation | role revoke/token expiry | +| raw query를 metric tag | cardinality/PII 폭증 | named operation/catalog | cardinality budget | +| usage 없이 field 삭제 | client breaking | deprecation + usage gate | schema diff | +| Federation 선제 도입 | distributed complexity | single schema default | composition opt-in gate | +| Base64 cursor를 신뢰 | client 변조 가능 | HMAC/version/fingerprint | tamper test | +| `totalCount` 항상 계산 | expensive count | explicit opt-in | count query regression | +| page size를 complexity에 미반영 | cheap-score bypass | list cardinality multiplier | `first=max` cost test | +| resolver timeout만 둠 | downstream work 지속 | cancellation/deadline propagation | timeout leak | +| reactive resolver에서 blocking DB | event-loop starvation | execution profile 검사 | event-loop blocking test | +| request-wide transaction | long TX·병렬성 상실 | mutation use-case TX | concurrency/lock test | +| field visibility를 auth로 사용 | 실제 object 권한 누락 | service/object auth | hidden-but-direct access | +| operation cache와 response cache 혼동 | stale/permission leak | cache 계층 분리 | cross-user test | +| Subscription unordered completion | async child fetch | explicit ORDERED profile | sequence test | +| slow client에서 무한 buffering | OOM | bounded buffer/close policy | slow consumer load | +| Schema와 resolver mapping 불일치 | silent null | startup inspector | application startup failure | + +### 단계별 구현 순서 + +**Foundation 단계**에서는 `graphql-core-api`, `graphql-schema`, `graphql-controller`, `graphql-http`, `graphql-error`, `graphql-security`, `graphql-testkit-core/http`를 구현합니다. 완료 조건은 SDL assembly, mapping inspection, HTTP media/status contract, immutable request context, DTO-only resolver convention, error masking, schema snapshot/compatibility test가 모두 CI에서 통과하는 것입니다. GraphQL Java의 SDL 권장 방식과 Spring의 schema resource/mapping inspection을 그대로 활용하고 재구현하지 않습니다. citeturn17search5turn18search3 + +```text +Foundation DONE += +Schema Contract ++ HTTP POST ++ Context ++ Resolver Boundary ++ Error Contract ++ Security ++ Testkit +``` + +**Execution Safety 단계**에서는 `graphql-dataloader`, `graphql-pagination`, `graphql-cost-control`, `graphql-observability`를 추가합니다. 완료 조건은 N+1 회귀 테스트, signed keyset cursor, max-page enforcement, depth/alias/complexity attack test, request timeout/cancellation, operation-name 기반 low-cardinality observation이 통과하는 것입니다. Spring의 DataLoader, connection adapter, Micrometer observation을 활용하되 플랫폼은 policy와 manifest만 추가합니다. citeturn14search5turn24view0turn14search2 + +```text +Execution Safety DONE += +No N+1 baseline regression ++ bounded pagination ++ cost budget ++ timeout/cancel ++ observability +``` + +**Operation Governance 단계**에서는 `graphql-persisted-operation`과 G4 Admin Plane을 구현합니다. 완료 조건은 immutable operation registry, schema hash 연동, client allowlist, operation block, usage measurement, preparsed cache와 registry 분리가 검증되는 것입니다. GraphQL Java의 `PreparsedDocumentProvider`는 parsed document cache일 뿐이라는 의미를 유지해야 합니다. citeturn16search4 + +```text +Governance DONE += +Persisted Registry ++ Operation Block ++ Schema Usage ++ Deprecation Gate ++ Cost Profile Management +``` + +**Realtime 단계**에서 `graphql-websocket`, 이후 필요할 때 `graphql-sse`를 활성화합니다. 완료 조건은 connection-init authentication, auth expiry, max connection age, cancellation, slow consumer, bounded buffer, source error, rolling shutdown, ordered/low-latency profile, 1k→목표 connection soak test입니다. Spring은 WebSocket/SSE transport와 ordering hook을 이미 제공하므로 플랫폼이 wire protocol을 새로 만들 필요는 없습니다. citeturn23view0turn23view1turn18search12 + +```text +Realtime DONE += +Auth lifecycle ++ bounded streaming ++ cancel ++ ordering profile ++ shutdown drain ++ load/soak test +``` + +**Extension 단계**에서만 `graphql-federation`, `graphql-codegen`, RSocket 등 G3 기능을 추가합니다. Federation Subgraph는 실제 독립 배포와 Schema ownership이 확인될 때만 Advanced로 승격하고, generated code는 GraphQL transport/client boundary에 한정합니다. Spring for GraphQL은 현재 Federation과 Codegen 양쪽 모두 공식 통합 지점을 제공하므로 Core에 별도 범용 framework를 만들 필요는 없습니다. citeturn17search1turn17search0 + +최종적으로 권장되는 플랫폼의 중심 API는 `GraphQL` 엔진 Wrapper가 아니라 다음 계약군입니다. + +```text +GraphQlSchemaContract +GraphQlRequestContext +GraphQlClientPolicy +GraphQlOperationPolicy +GraphQlCostPolicy +GraphQlFetchProfile +GraphQlBatchPolicy +GraphQlCursorCodec +GraphQlErrorContract +PersistedOperationRegistry +GraphQlSubscriptionPolicy +GraphQlObservationConvention +SchemaCompatibilityPolicy +``` + +그리고 도메인 개발자에게 보이는 일상적인 코드는 가능한 한 평범하게 유지합니다. + +```text +SDL fragment ++ +@QueryMapping / @MutationMapping / @SchemaMapping / @BatchMapping ++ +Application Use Case ++ +DTO / Read Model +``` + +이것이 Spring for GraphQL과 GraphQL Java가 이미 잘하는 부분을 다시 추상화하지 않으면서도, **Schema 계약 → transport → 인증/context → parse/validation → operation/cost → resolver → DataLoader → application → partial result/error → streaming**이라는 전체 실행 경로에 일관된 안전 정책을 부여하는 가장 적절한 구조입니다. Spring의 현재 설계도 transport가 `ExecutionGraphQlService`를 호출하고, annotated controller가 DataFetcher로 연결되며, Spring Data 통합은 선택 사항으로 제공되는 계층 구조를 취하고 있습니다. citeturn18search0turn18search2turn24view0 \ No newline at end of file diff --git a/docs/graphql-superpowers-package/validate_graphql_docs.py b/docs/graphql-superpowers-package/validate_graphql_docs.py new file mode 100755 index 00000000..7759f090 --- /dev/null +++ b/docs/graphql-superpowers-package/validate_graphql_docs.py @@ -0,0 +1,249 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +from pathlib import Path +import re +import sys +import hashlib + +ROOT = Path(__file__).resolve().parent +DESIGN = ROOT / "docs/superpowers/specs/2026-08-12-graphql-api-execution-platform-design.md" +STABLE = ROOT / "docs/superpowers/plans/2026-08-12-graphql-api-execution-platform-implementation-plan.md" +ADVANCED = ROOT / "docs/superpowers/plans/2026-08-12-graphql-advanced-capabilities-expansion-plan.md" + +checks: list[tuple[str, bool, str]] = [] + +def check(name: str, condition: bool, detail: str = "") -> None: + checks.append((name, bool(condition), detail)) + +def read(path: Path) -> str: + check(f"file exists: {path.name}", path.exists(), str(path)) + return path.read_text(encoding="utf-8") if path.exists() else "" + +design = read(DESIGN) +stable = read(STABLE) +advanced = read(ADVANCED) + +# Basic document integrity +check("design line floor", len(design.splitlines()) >= 2000, str(len(design.splitlines()))) +check("stable plan line floor", len(stable.splitlines()) >= 4000, str(len(stable.splitlines()))) +check("advanced plan line floor", len(advanced.splitlines()) >= 1500, str(len(advanced.splitlines()))) +for label, text in [("design", design), ("stable", stable), ("advanced", advanced)]: + check(f"{label} code fences balanced", text.count("```") % 2 == 0, str(text.count("```"))) + for marker in ["TODO", "TBD", "FIXME", "implement later", "fill in details"]: + check(f"{label} no placeholder {marker}", marker.lower() not in text.lower()) + +# Design required sections and source traceability +required_design_terms = [ + "# GraphQL API 실행 플랫폼 설계서", + "GraphQL Platform owns", + "Domain/Application owns", + "G1 Standard GraphQL API", + "G2 Advanced Execution", + "G3 GraphQL Extension", + "G4 Admin Plane", + "SDL", + "September 2025", + "application/graphql-response+json", + "HTTP `200`", + "GraphQlRequestContext", + "DataLoader", + "GraphQlFetchProfile", + "HMAC", + "Idempotency", + "Partial Data", + "Persisted Operation", + "Subscription", + "Federation", + "GraphQL Multipart Upload", + "Fileserver", + "부록 B. 입력 심층 리서치 원문", + "# GraphQL API 실행 플랫폼 심층 리서치", +] +for term in required_design_terms: + check(f"design contains {term}", term in design) + +# Critical design invariants +critical_pairs = [ + ("field error uses HTTP 200", "field error" in design.lower() and "HTTP `200`" in design), + ("no draft 294 stable", "294" in design and "Stable" in design), + ("dataloader request scope", "request" in design.lower() and "DataLoader" in design), + ("cursor HMAC", "Cursor" in design and "HMAC" in design), + ("no multipart upload", "Multipart Upload" in design and "Fileserver" in design), + ("single schema default", "Single Executable Schema" in design), + ("request-wide transaction prohibited", "request-wide" in design.lower() and "transaction" in design.lower()), + ("entity/document boundary", "JPA Entity" in design and "MongoDB Document" in design), +] +for name, condition in critical_pairs: + check(name, condition) + +# Plan headers and global constraints +stable_header_terms = [ + "# GraphQL API 실행 플랫폼 Implementation Plan", + "REQUIRED SUB-SKILL", + "**Goal:**", + "**Architecture:**", + "**Tech Stack:**", + "## Global Constraints", + "Stable Task", +] +advanced_header_terms = [ + "# GraphQL Advanced Capability Expansion Implementation Plan", + "REQUIRED SUB-SKILL", + "backend.graphql.advanced.*", + "Stable 구현 계획 Task `1–48`", +] +for term in stable_header_terms: + check(f"stable header contains {term}", term in stable) +for term in advanced_header_terms: + check(f"advanced header contains {term}", term in advanced) + +# Task sequence and per-task structure +def task_sections(text: str) -> list[tuple[int, str]]: + matches = list(re.finditer(r"^### Task (\d+): .+$", text, re.MULTILINE)) + result = [] + for i, match in enumerate(matches): + start = match.start() + end = matches[i+1].start() if i+1 < len(matches) else len(text) + result.append((int(match.group(1)), text[start:end])) + return result + +stable_tasks = task_sections(stable) +advanced_tasks = task_sections(advanced) +check("stable task count", len(stable_tasks) == 48, str(len(stable_tasks))) +check("advanced task count", len(advanced_tasks) == 19, str(len(advanced_tasks))) +check("stable task sequence", [n for n, _ in stable_tasks] == list(range(1, 49))) +check("advanced task sequence", [n for n, _ in advanced_tasks] == list(range(1, 20))) + +def validate_tasks(label: str, tasks: list[tuple[int, str]]) -> None: + required = [ + "**Files:**", + "**Interfaces:**", + "**Implementation requirements:**", + "**Step 1: Write the failing test**", + "**Step 2: Run the focused test and verify the failure**", + "**Step 3: Implement the smallest complete production contract**", + "**Step 4: Run the focused test and the owning suite**", + "**Step 5: Commit the independently reviewable change**", + "Expected: FAIL", + "Expected: PASS", + "git commit -m", + ] + for number, section in tasks: + for token in required: + check(f"{label} task {number} contains {token}", token in section) + check(f"{label} task {number} has test path", "- Test: `" in section) + check(f"{label} task {number} has production file", "- Create: `" in section) + check(f"{label} task {number} fences balanced", section.count("```") % 2 == 0) + check(f"{label} task {number} has gradle test", "./gradlew" in section and ":test" in section) + +validate_tasks("stable", stable_tasks) +validate_tasks("advanced", advanced_tasks) + +# Create paths +def create_paths(text: str) -> list[str]: + return re.findall(r"^- Create: `([^`]+)`$", text, re.MULTILINE) + +stable_paths = create_paths(stable) +advanced_paths = create_paths(advanced) +check("stable create paths exist", len(stable_paths) >= 150, str(len(stable_paths))) +check("advanced create paths exist", len(advanced_paths) >= 80, str(len(advanced_paths))) +check("stable create paths unique", len(stable_paths) == len(set(stable_paths))) +check("advanced create paths unique", len(advanced_paths) == len(set(advanced_paths))) +check("stable and advanced paths disjoint", set(stable_paths).isdisjoint(advanced_paths)) +for index, path in enumerate(stable_paths, 1): + check(f"stable create path {index} exact", "*" not in path and "..." not in path and (path.startswith("modules/graphql/") or path.startswith("build-logic/"))) +for index, path in enumerate(advanced_paths, 1): + check(f"advanced create path {index} exact", "*" not in path and "..." not in path and path.startswith("modules/graphql-advanced/")) + +# Stable/Advanced separation +for forbidden in [ + "modules/graphql/graphql-websocket/", + "modules/graphql/graphql-federation/", + "modules/graphql/graphql-persisted-operation/", + "modules/graphql/graphql-rsocket/", +]: + check(f"stable excludes {forbidden}", forbidden not in stable) + +for required in [ + "modules/graphql-advanced/graphql-persisted-operation/", + "modules/graphql-advanced/graphql-websocket/", + "modules/graphql-advanced/graphql-subscription/", + "modules/graphql-advanced/graphql-federation/", + "modules/graphql-advanced/graphql-rsocket/", +]: + check(f"advanced includes {required}", required in advanced) + +# Stable coverage +stable_required_terms = [ + "GraphQlRequestContext", + "GraphQlClientPolicy", + "GraphQlSchemaContract", + "SchemaMappingInspector", + "@oneOf", + "GraphQlHttpProfile", + "application/graphql-response+json", + "GraphQlExecutionProfile", + "GraphQlWireError", + "GraphQlTenantIsolationPolicy", + "GraphQlParserLimits", + "GraphQlComplexityCalculator", + "GraphQlRuntimeBudget", + "GraphQlPreparsedCacheKey", + "GraphQlBatchPolicy", + "GraphQlFetchProfile", + "HmacGraphQlCursorCodec", + "GraphQlConnection", + "GraphQlMutationIdempotencyContext", + "GraphQlMetricCardinalityPolicy", + "GraphQlPlatformStartupValidator", + "GraphQlReleaseGate", +] +for term in stable_required_terms: + check(f"stable coverage {term}", term in stable) + +advanced_required_terms = [ + "GraphQlPersistedOperation", + "GraphQlWebSocketProtocol", + "GraphQlSubscriptionBufferPolicy", + "GraphQlSubscriptionOrderingProfile", + "GraphQlSseConnectionPolicy", + "GraphQlReplayPosition", + "GraphQlDataLoaderDependencyGraph", + "GraphQlFederationEntityKey", + "GraphQlFederationCompositionGate", + "GraphQlGeneratedSourceBoundary", + "GraphQlRepositoryAllowlist", + "GraphQlRSocketRoutePolicy", + "GraphQlHttpGetOperationPolicy", + "GraphQlIncrementalCompatibilityGate", + "GraphQlAdvancedReleaseGate", +] +for term in advanced_required_terms: + check(f"advanced coverage {term}", term in advanced) + +# Prohibited API patterns +prohibited_patterns = [ + (r"interface\s+GenericGraphQlRepository", "no generic graphql repository"), + (r"public\s+.*\bEntityManager\b", "no public entity manager"), + (r"public\s+.*\bMongoTemplate\b", "no public mongo template"), + (r"scalar\s+Upload\b", "no upload scalar declaration"), + (r"@Transactional\s+.*GraphQL request", "no request-wide transaction implementation"), +] +for pattern, name in prohibited_patterns: + check(name, re.search(pattern, stable, re.IGNORECASE | re.MULTILINE) is None) + +# File hashes can be printed for package evidence +for path in [DESIGN, STABLE, ADVANCED]: + if path.exists(): + digest = hashlib.sha256(path.read_bytes()).hexdigest() + check(f"sha256 computed: {path.name}", len(digest) == 64, digest) + +failed = [(n, d) for n, ok, d in checks if not ok] +print(f"CHECKS={len(checks)}") +print(f"PASSED={len(checks)-len(failed)}") +print(f"FAILED={len(failed)}") +for name, detail in failed: + print(f"FAIL: {name}" + (f" :: {detail}" if detail else "")) + +sys.exit(1 if failed else 0) diff --git a/docs/httpclient/repository-adaptation.md b/docs/httpclient/repository-adaptation.md index a4ca9738..87e2194f 100644 --- a/docs/httpclient/repository-adaptation.md +++ b/docs/httpclient/repository-adaptation.md @@ -63,7 +63,7 @@ Root package: `io.backend.skeleton.httpclient` → `dev.caskeleton.adapter.outbo | Design assumption | Repository reality | Adaptation | |---|---|---| | Gradle Kotlin DSL, `build-logic` convention plugin | Groovy DSL, root `build.gradle` conventions, `LockMode.STRICT` dependency locking | Dependencies declared in `src/adapter/outbound/httpclient/build.gradle`; `gradle.lockfile` regenerated. | -| Spring Framework 6.2 baseline with 7.0 compatibility | Spring Boot 4.0.0 / Spring Framework 7.0 is the repository baseline | Common code targets the Spring 6.2 **API surface** (no 6.2-only or 7.0-only classes in common packages). The Spring 7 HTTP Service Group integration stays isolated in `…httpclient.spring7`, exactly as the design requires. | +| Spring Framework 6.2 baseline with 7.0 compatibility | Spring Boot 4.0.8 / Spring Framework 7.0 is the repository baseline | Common code targets the Spring 6.2 **API surface** (no 6.2-only or 7.0-only classes in common packages). The Spring 7 HTTP Service Group integration stays isolated in `…httpclient.spring7`, exactly as the design requires. | | `settings.gradle.kts` module registration | Fail-closed registry | No registry change; leaf identity, gradle path, allowed dependencies unchanged. | | Design §6.2 grades Apache HttpClient 5 as HTTP/2-capable | Spring's blocking factory drives Apache's **classic** client, which is HTTP/1.1 only; HTTP/2 lives in Apache's async client | `ApacheBlockingTransportProvider` declares HTTP/1.1 and rejects an HTTP/2 profile at startup. Blocking HTTP/2 is served by the JDK transport, measured by `NegotiatedProtocolContractTest`. | | Design §28.1 names WireMock for stateful fixtures | WireMock's Jetty modules bind a different Jetty 12 ABI than the Boot-managed one this module already needs for HTTP/3, and fail at server start | `StatefulUpstream` provides path-keyed stateful responses on the existing fixture server; the WireMock dependency was removed rather than worked around with a shaded jar | diff --git a/docs/jpa/repository-adaptation.md b/docs/jpa/repository-adaptation.md index 7dfe1a69..bcf9e00f 100644 --- a/docs/jpa/repository-adaptation.md +++ b/docs/jpa/repository-adaptation.md @@ -95,7 +95,7 @@ Docker-dependent lanes fail closed rather than skipping, matching the existing | Plan assumption | Repository reality | Adaptation | |---|---|---| | Gradle Kotlin DSL, `build-logic` convention plugin, `jpa-library-conventions.gradle.kts` | Groovy DSL, root `src/build.gradle` conventions (spotless google-java-format, checkstyle, SpotBugs + FindSecBugs, ErrorProne, `-Werror`, one-type-per-file), `LockMode.STRICT` dependency locking | Source sets and dependencies declared in `src/adapter/outbound/persistence-jpa/build.gradle`; `gradle.lockfile` regenerated with `resolveAndLockAll --write-locks`. | -| Spring Boot 4.1 dependency management, Spring Data JPA 4.1 | Repository baseline is Spring Boot 4.0.0 | Versions are inherited from the repository BOM and never pinned per module, exactly as the plan requires ("do not override Hibernate/Flyway/Hikari versions outside the Boot BOM"). | +| Spring Boot 4.1 dependency management, Spring Data JPA 4.1 | Repository baseline is Spring Boot 4.0.8 | Versions are inherited from the repository BOM and never pinned per module, exactly as the plan requires ("do not override Hibernate/Flyway/Hikari versions outside the Boot BOM"). | | Hibernate ORM 7.4 is the Stable provider | Boot 4.0.0 resolves `org.hibernate.orm:hibernate-core:7.1.8.Final` | The *declared* Stable provider baseline of the design stays 7.4 in `HibernateProviderPolicy`; the runtime provider version is read from Hibernate itself and reported. The collection-fetch-pagination gate runs against whatever provider the BOM resolves, and `HibernateProviderPolicy.driftsFromDeclaredBaseline()` makes the difference visible instead of hiding it behind a green check. | | PostgreSQL 16·17·18 Stable matrix | This leaf's existing evidence image is `postgres:16-alpine` | `PostgreSqlVersion` declares exactly PG 16, 17, 18. The default lane runs the repository's existing 16 image; 17 and 18 are selected by `-Pjpa.matrix.versions=16,17,18`, and an unknown or empty selection is an error rather than a skip. | | `settings.gradle.kts` module registration | Fail-closed 19-leaf registry | No registry change: leaf identity, Gradle path, allowed dependencies, and runtime memberships are unchanged. | diff --git a/docs/mongodb/repository-adaptation.md b/docs/mongodb/repository-adaptation.md index 70cea904..553d48e4 100644 --- a/docs/mongodb/repository-adaptation.md +++ b/docs/mongodb/repository-adaptation.md @@ -91,7 +91,7 @@ otherwise. Being on the classpath is not being enabled. |---|---|---| | Gradle Kotlin DSL under `modules/mongodb*` | Groovy DSL, root `build.gradle` conventions, `LockMode.STRICT` locking | Dependencies declared in `src/adapter/outbound/persistence-mongo/build.gradle`; `gradle.lockfile` regenerated. | | `mongodb-spring-boot-starter` is a separate module the app depends on | `modules.json` gives `adapter-outbound-persistence-mongo` `runtime_memberships: []` and does **not** list it among `app-bootstrap`'s allowed dependencies | The `autoconfigure` package stays inside the leaf and registers through the leaf's own `META-INF/spring/…AutoConfiguration.imports`. This differs from the httpclient precedent, where the starter moved to `:app-bootstrap`; here the registry forbids that edge. | -| Spring Boot 4.1 / Spring Data MongoDB 5.1 baseline | Repository baseline is Spring Boot 4.0.0 / Spring Data MongoDB 5.0.0 | The platform targets the Spring Data MongoDB **API surface** common to both; no 5.1-only type is referenced. The support matrix records the actual pinned versions. | +| Spring Boot 4.1 / Spring Data MongoDB 5.1 baseline | Repository baseline is Spring Boot 4.0.8 / Spring Data MongoDB 5.0.x | The platform targets the Spring Data MongoDB **API surface** common to both; no 5.1-only type is referenced. The support matrix records the actual pinned versions. | | `MongoRetryScope` lives in `mongodb-transaction` | The `mongodb-spring-data` failure translator must classify retry scope, and it cannot depend on `mongodb-transaction` | `MongoRetryScope` lives in `…api.error` (core-api), which both packages already depend on. Same values, same meaning, one legal position in the DAG. | | `mongodb-migration-flamingock` depends on Flamingock | Adding an unvetted external dependency is out of scope for this task, and the design itself requires the public contract not to depend on Flamingock types | The adapter is provider-neutral: it consumes a platform-owned `FlamingockChangeUnitView`. Wiring an actual Flamingock distribution is a one-file change behind that view. | | Testkit as its own Gradle module | The design forbids production modules depending on the testkit | A dedicated `testkit` source set whose output is on the test compile/runtime classpaths only. ArchUnit rule `productionNeverDependsOnTestkit` enforces the direction. | diff --git a/docs/web-superpowers-package/MANIFEST.sha256 b/docs/web-superpowers-package/MANIFEST.sha256 new file mode 100644 index 00000000..cc5ecb97 --- /dev/null +++ b/docs/web-superpowers-package/MANIFEST.sha256 @@ -0,0 +1,7 @@ +f3e98b6e72a3a43c38799aac7a333387afa12d7d007dc6966e58a3d725c0f4b7 README.md +c04f805a3f05bee50cf3d62fc531dfc9a33664ee2621b5281b1f3c0ec0885dca VALIDATION.md +27c0ddd7ab0f390fe074f14d0cb14e815c60e7544eabe8aa7a2e4ae462f91cad docs/superpowers/plans/2026-08-13-web-advanced-capabilities-expansion-plan.md +c780fb635da38b7a1c2f2f73969e129c5ac72d1852133dcb29fc10701fa453c5 docs/superpowers/plans/2026-08-13-web-inbound-http-api-execution-platform-implementation-plan.md +7b82fd9850878a5f43828243ddec992b3fae0066d31eaa898b9e1e13528bace7 docs/superpowers/specs/2026-08-13-web-inbound-http-api-execution-platform-design.md +b8c9f6d7ac055653a425f62458ece3c68ee8d7dc48827c7c28266947fadb5daf research/source-web-deep-research.md +9af756a602f320abee5f40ad7dd71936644ee28aef30b442e72f85e0b8086d0f validate_web_docs.py diff --git a/docs/web-superpowers-package/README.md b/docs/web-superpowers-package/README.md new file mode 100644 index 00000000..46fbdb8d --- /dev/null +++ b/docs/web-superpowers-package/README.md @@ -0,0 +1,73 @@ +# Web Superpowers Package + +Spring Boot 기반 **인바운드 HTTP API 실행 플랫폼 `web`**의 설계, Stable 구현 계획, Advanced 확장 계획, 원본 심층 리서치, 정적 검증 도구를 묶은 패키지입니다. + +## 구성 + +```text +web-superpowers-package/ +├── docs/ +│ └── superpowers/ +│ ├── specs/ +│ │ └── 2026-08-13-web-inbound-http-api-execution-platform-design.md +│ └── plans/ +│ ├── 2026-08-13-web-inbound-http-api-execution-platform-implementation-plan.md +│ └── 2026-08-13-web-advanced-capabilities-expansion-plan.md +├── research/ +│ └── source-web-deep-research.md +├── README.md +├── VALIDATION.md +├── validate_web_docs.py +└── MANIFEST.sha256 +``` + +## 문서 규모 + +| 항목 | 규모 | +|---|---:| +| 설계서 | 1,656행 | +| Stable 구현 계획 | 4,762행 / 58 Task | +| Advanced 계획 | 1,532행 / 19 Task | +| 원본 리서치 | 1,597행 | + +## 핵심 구현 순서 + +```text +Stable Task 1~58 +→ Stable Release Gate +→ Advanced Task 1~19 +→ Advanced Promotion Gate +``` + +Stable 구현 완료 전 Advanced module을 적용하지 않습니다. + +## 정적 검증 + +```bash +./validate_web_docs.py +sha256sum -c MANIFEST.sha256 +``` + +## 실행 방식 + +구현 시 `superpowers:subagent-driven-development`를 권장합니다. + +각 Task마다 다음 review를 분리합니다. + +```text +1. Specification review +2. Code quality·test evidence review +``` + +## 명시적 가정 + +```text +Java 21 +Spring Boot 4.1 BOM +Gradle Kotlin DSL +root package: io.backend.skeleton.web +stable modules: modules/web +advanced modules: modules/web-advanced +``` + +실제 저장소가 포함되지 않았으므로 이 패키지는 설계·계획·정적 검증 산출물이며, Gradle compile과 서버·DB·Redis·Nginx 통합 시험 결과를 포함하지 않습니다. diff --git a/docs/web-superpowers-package/VALIDATION.md b/docs/web-superpowers-package/VALIDATION.md new file mode 100644 index 00000000..27a0f631 --- /dev/null +++ b/docs/web-superpowers-package/VALIDATION.md @@ -0,0 +1,193 @@ +# Web Superpowers 문서 정적 검증 결과 + +검증 기준일: 2026-08-13 + +## 1. 검증 대상 + +| 문서 | 행 수 | +|---|---:| +| `web-inbound-http-api-execution-platform-design.md` | 1,656 | +| `web-inbound-http-api-execution-platform-implementation-plan.md` | 4,762 | +| `web-advanced-capabilities-expansion-plan.md` | 1,532 | +| 원본 심층 리서치 | 1,597 | + +## 2. 구조 검증 + +| 항목 | 결과 | +|---|---:| +| 실행 검사 | 1,097 | +| 통과 | 1,097 | +| 실패 | 0 | +| Stable Task | 58 | +| Advanced Task | 19 | +| Stable Create 경로 | 289 | +| Advanced Create 경로 | 94 | +| Stable Task 번호 연속성 | PASS | +| Advanced Task 번호 연속성 | PASS | +| 모든 Task의 Files·Interfaces | PASS | +| 모든 Task의 Implementation requirements | PASS | +| 모든 Task의 Step 1~5 | PASS | +| 모든 Task의 실패·통과 예상 결과 | PASS | +| 모든 Task의 Git commit 명령 | PASS | +| Stable Create 경로 중복 | 없음 | +| Advanced Create 경로 중복 | 없음 | +| Stable·Advanced Create 경로 충돌 | 없음 | +| Markdown code fence 균형 | PASS | +| `TODO`·`TBD`·`FIXME` | 없음 | + +검증 명령: + +```bash +cd /mnt/data +./validate_web_docs.py +``` + +실행 결과: + +```text +checks=1097 passed=1097 failed=0 +``` + +## 3. 설계 핵심 계약 검증 + +다음 계약이 설계서와 계획서에 모두 존재하는지 확인했습니다. + +```text +W1 / W2 / W3 / W4 공개 계층 +MVC와 WebFlux Starter 상호 배타성 +Request / Application / Response Evidence 분리 +APPLICATION_COMMITTED와 HTTP response delivery 분리 +RFC 9457 Problem Details +400 / 422 분리 +409 / 412 분리 +OpenAPI 3.1.2 Stable +Path major API version +HMAC keyset cursor +ETag / If-Match +Idempotency semantic fingerprint +Redis가 DB commit evidence의 유일한 source가 아님 +Business mutation + JPA idempotency evidence same transaction +Application commit 후 response-loss fault test +Durable acceptance 이후에만 202 +Trusted Nginx forwarded-header boundary +Tomcat / Jetty / Reactor Netty / Nginx 실제 gate +Rate limit 429와 Admission 503 분리 +Low-cardinality metric·access log +Stable·Advanced dependency 격리 +``` + +## 4. 계획 완결성 검증 + +Stable 계획은 다음 단계로 구성됩니다. + +```text +Task 1~14 +→ Core·HTTP·JSON·Problem·Architecture Foundation + +Task 15~22 +→ MVC·Tomcat·Jetty·WebFlux·Reactor Netty + +Task 23~30 +→ Security·Proxy·Versioning·Route·OpenAPI + +Task 31~36 +→ Collection Query·Cursor·Conditional·Evidence + +Task 37~43 +→ Idempotency·JPA Evidence·Redis Gate·Response Loss + +Task 44~48 +→ Durable Operation·Outbox·HTTP Resource·Cache + +Task 49~55 +→ Budget·Rate·Admission·CORS/CSRF·Order·Observability·Nginx + +Task 56~58 +→ Cross-stack Contract·Performance·Stable Release +``` + +Advanced 계획은 다음 단계로 구성됩니다. + +```text +Task 1~3 +→ Module isolation·Virtual Thread·Controlled Blocking Bridge + +Task 4~5 +→ JSON Merge Patch·JSON Patch + +Task 6~13 +→ Streaming Core·SSE·NDJSON·JSON Sequence·Drain·Replay + +Task 14~18 +→ Functional WebFlux·CBOR·XML·OpenAPI 3.2·RateLimit Draft + +Task 19 +→ Soak·Rollback·Promotion Gate +``` + +## 5. 검증 범위의 한계 + +현재 검증은 **문서의 구조, 요구사항 추적성, 내부 계약, 파일 경로, 작업 순서에 대한 정적 검증**입니다. + +실제 Backend Skeleton 저장소가 이번 입력에 포함되지 않았으므로 다음은 실행한 상태가 아닙니다. + +```text +Gradle configuration·compile +Spring Boot ApplicationContext 기동 +Tomcat·Jetty·Reactor Netty 실제 contract +PostgreSQL JPA idempotency transaction +Redis concurrent gate +Messaging outbox operation +Nginx TLS·Forwarded topology +commit 후 TCP reset fault injection +OpenAPI generated client compile +abuse·load·graceful shutdown +Git commit +``` + +계획서의 경로와 package는 다음 명시적 가정을 사용합니다. + +```text +Java 21 +Gradle Kotlin DSL +Spring Boot 4.1 BOM +root package: io.backend.skeleton.web +stable module root: modules/web +advanced module root: modules/web-advanced +``` + + +## 6. 패키지 무결성 검증 + +패키지 조립 후 다음 검증을 추가로 수행했습니다. + +```text +Package-local validator +→ checks=1104 passed=1104 failed=0 + +MANIFEST.sha256 +→ 모든 7개 파일 OK + +ZIP CRC +→ No errors detected + +독립 문서와 패키지 내부 문서 +→ byte 단위 일치 +``` + +검증 명령: + +```bash +cd /mnt/data/web-superpowers-package +./validate_web_docs.py +sha256sum -c MANIFEST.sha256 + +cd /mnt/data +unzip -t web-superpowers-package.zip +cmp web-inbound-http-api-execution-platform-design.md \ + web-superpowers-package/docs/superpowers/specs/2026-08-13-web-inbound-http-api-execution-platform-design.md +cmp web-inbound-http-api-execution-platform-implementation-plan.md \ + web-superpowers-package/docs/superpowers/plans/2026-08-13-web-inbound-http-api-execution-platform-implementation-plan.md +cmp web-advanced-capabilities-expansion-plan.md \ + web-superpowers-package/docs/superpowers/plans/2026-08-13-web-advanced-capabilities-expansion-plan.md +``` diff --git a/docs/web-superpowers-package/docs/superpowers/plans/2026-08-13-web-advanced-capabilities-expansion-plan.md b/docs/web-superpowers-package/docs/superpowers/plans/2026-08-13-web-advanced-capabilities-expansion-plan.md new file mode 100644 index 00000000..ee777f6a --- /dev/null +++ b/docs/web-superpowers-package/docs/superpowers/plans/2026-08-13-web-advanced-capabilities-expansion-plan.md @@ -0,0 +1,1532 @@ +# 인바운드 HTTP API 실행 플랫폼 `web` Advanced Capability 확장 계획서 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Stable 인바운드 HTTP 플랫폼의 wire·security·evidence 계약을 유지하면서 Virtual Thread MVC, controlled WebFlux blocking bridge, RFC Patch, SSE·NDJSON·JSON Sequence, optional codecs, OpenAPI 3.2와 draft headers를 격리된 opt-in 기능으로 구현한다. + +**Architecture:** Stable 모듈은 Advanced에 의존하지 않으며 모든 기능은 `backend.web.advanced.*` feature flag 뒤에서 등록된다. Streaming은 live delivery와 bounded buffering만 소유하고 durable replay는 Messaging에 위임한다. OpenAPI 3.2와 RateLimit draft는 Stable 3.1.2·429/Retry-After 계약을 대체하지 않는 별도 compatibility lane으로 운영한다. + +**Tech Stack:** Java 21, Spring Boot 4.1 BOM, Spring MVC, Spring WebFlux, Reactor Netty, Virtual Threads, RFC 7396, RFC 6902, SSE, NDJSON, JSON Text Sequences, Jackson CBOR/XML, OpenAPI 3.2 Experimental, Messaging replay bridge, Nginx, Testcontainers, load/soak test. + +## Global Constraints + +- Stable Task 1~58과 Stable Release Gate 완료 전 Advanced 구현을 시작하지 않는다. +- Stable module은 `modules/web-advanced`에 의존하지 않는다. +- 모든 Advanced capability는 명시적 feature flag와 startup validation이 필요하다. +- Advanced 기능이 비활성화된 상태의 Stable behavior와 artifact hash가 바뀌지 않아야 한다. +- Virtual Thread와 blocking bridge는 admission, DB pool, outbound bulkhead 제한을 우회하지 않는다. +- PATCH는 field/pointer authorization, full validation, If-Match를 우회하지 않는다. +- Streaming response commit 이후 HTTP status를 RFC 9457 Problem으로 다시 변경하지 않는다. +- Slow consumer의 기본 정책은 무한 buffering이 아니라 bounded close다. +- Web은 durable replay history를 소유하지 않고 Messaging/Event Log cursor를 bridge한다. +- OpenAPI 3.1.2가 Stable source of truth이며 3.2는 Experimental artifact다. +- Draft RateLimit headers는 429·Retry-After Stable 계약을 대체하지 않는다. +- CBOR/XML은 JSON wire/security budget보다 완화된 coercion이나 polymorphism을 허용하지 않는다. +- Advanced 승격에는 soak, security, compatibility, rollback evidence와 ADR가 필요하다. + +--- + +## 1. Advanced 파일·모듈 구조 + +```text +modules/web-advanced/ +├── web-advanced-bootstrap +├── web-streaming-core +├── web-streaming-mvc +├── web-streaming-webflux +├── web-patch +├── web-functional-webflux +├── web-codec-cbor +├── web-codec-xml +├── web-openapi-32-experimental +├── web-ratelimit-draft-experimental +└── web-advanced-testkit +``` + +## 2. 실행 순서 + +```text +Task 1–3 +→ Module isolation·Virtual Thread·Controlled Blocking Bridge + +Task 4–5 +→ JSON Merge Patch·JSON Patch + +Task 6–13 +→ Streaming Core·MVC/WebFlux SSE·NDJSON·JSON Sequence·Error·Drain·Replay + +Task 14–18 +→ Functional WebFlux·CBOR·XML·OpenAPI 3.2·RateLimit Draft + +Task 19 +→ Soak·Rollback·Promotion Gate +``` + +--- + +### Task 1: Advanced 모듈·Feature Flag·Dependency 격리 + +**Files:** +- Create: `modules/web-advanced/build.gradle.kts` +- Create: `modules/web-advanced/web-advanced-bootstrap/build.gradle.kts` +- Create: `modules/web-advanced/web-streaming-core/build.gradle.kts` +- Create: `modules/web-advanced/web-streaming-mvc/build.gradle.kts` +- Create: `modules/web-advanced/web-streaming-webflux/build.gradle.kts` +- Create: `modules/web-advanced/web-patch/build.gradle.kts` +- Create: `modules/web-advanced/web-functional-webflux/build.gradle.kts` +- Create: `modules/web-advanced/web-codec-cbor/build.gradle.kts` +- Create: `modules/web-advanced/web-codec-xml/build.gradle.kts` +- Create: `modules/web-advanced/web-openapi-32-experimental/build.gradle.kts` +- Create: `modules/web-advanced/web-ratelimit-draft-experimental/build.gradle.kts` +- Create: `modules/web-advanced/web-advanced-testkit/build.gradle.kts` +- Create: `modules/web-advanced/web-advanced-bootstrap/src/main/java/io/backend/skeleton/web/advanced/bootstrap/WebAdvancedFeature.java` +- Create: `modules/web-advanced/web-advanced-bootstrap/src/main/java/io/backend/skeleton/web/advanced/bootstrap/WebAdvancedFeatureFlags.java` +- Modify: `settings.gradle.kts` +- Test: `modules/web-advanced/web-advanced-bootstrap/src/test/java/io/backend/skeleton/web/advanced/bootstrap/WebAdvancedIsolationTest.java` + +**Interfaces:** +- Consumes: Stable Task 58 release gate +- Produces: feature-flagged Advanced modules with no reverse dependency from Stable modules + +**Implementation requirements:** +- Advanced 작업은 Stable Task 1~58과 Stable Release Gate 완료 후 시작한다. +- Stable Starter는 Advanced module을 transitively 포함하지 않는다. +- 각 기능은 `backend.web.advanced.*` 명시 flag 없이는 bean을 등록하지 않는다. + +- [ ] **Step 1: Write the failing test** + +```java +class WebAdvancedIsolationTest { + @org.junit.jupiter.api.Test + void stableModulesDoNotDependOnAdvancedModules() { + var graph = ModuleDependencyGraph.load(); + org.junit.jupiter.api.Assertions.assertTrue( + graph.dependenciesFrom("modules/web").stream() + .noneMatch(path -> path.startsWith("modules/web-advanced")) + ); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the expected failure** + +Run: `./gradlew :modules:web-advanced:web-advanced-bootstrap:test --tests '*WebAdvancedIsolationTest'` + +Expected: FAIL because advanced modules and dependency isolation do not exist. + +- [ ] **Step 3: Implement the minimum production contract** + +```java +public enum WebAdvancedFeature { + MVC_VIRTUAL_THREADS, + WEBFLUX_BLOCKING_BRIDGE, + JSON_MERGE_PATCH, + JSON_PATCH, + SSE, + NDJSON, + JSON_SEQUENCE, + FUNCTIONAL_WEBFLUX, + CBOR, + XML, + OPENAPI_32, + RATELIMIT_DRAFT_HEADERS +} + +public final class WebAdvancedFeatureFlags { + private final java.util.Set enabled; + + public boolean enabled(WebAdvancedFeature feature) { + return enabled.contains(feature); + } +} +``` + +- [ ] **Step 4: Run the task test and its module contract suite** + +Run: `./gradlew :modules:web-advanced:web-advanced-bootstrap:test` + +Expected: PASS with no skipped contract assertions and no new warnings. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/web-advanced/build.gradle.kts' 'modules/web-advanced/web-advanced-bootstrap/build.gradle.kts' 'modules/web-advanced/web-streaming-core/build.gradle.kts' 'modules/web-advanced/web-streaming-mvc/build.gradle.kts' 'modules/web-advanced/web-streaming-webflux/build.gradle.kts' 'modules/web-advanced/web-patch/build.gradle.kts' 'modules/web-advanced/web-functional-webflux/build.gradle.kts' 'modules/web-advanced/web-codec-cbor/build.gradle.kts' 'modules/web-advanced/web-codec-xml/build.gradle.kts' 'modules/web-advanced/web-openapi-32-experimental/build.gradle.kts' 'modules/web-advanced/web-ratelimit-draft-experimental/build.gradle.kts' 'modules/web-advanced/web-advanced-testkit/build.gradle.kts' 'modules/web-advanced/web-advanced-bootstrap/src/main/java/io/backend/skeleton/web/advanced/bootstrap/WebAdvancedFeature.java' 'modules/web-advanced/web-advanced-bootstrap/src/main/java/io/backend/skeleton/web/advanced/bootstrap/WebAdvancedFeatureFlags.java' 'settings.gradle.kts' 'modules/web-advanced/web-advanced-bootstrap/src/test/java/io/backend/skeleton/web/advanced/bootstrap/WebAdvancedIsolationTest.java' +git commit -m "build(web): isolate advanced web capabilities" +``` + +### Task 2: MVC Virtual Thread 실행 Profile + +**Files:** +- Create: `modules/web-advanced/web-advanced-bootstrap/src/main/java/io/backend/skeleton/web/advanced/virtualthread/VirtualThreadMvcConfiguration.java` +- Create: `modules/web-advanced/web-advanced-bootstrap/src/main/java/io/backend/skeleton/web/advanced/virtualthread/VirtualThreadAdmissionGuard.java` +- Create: `modules/web-advanced/web-advanced-bootstrap/src/main/java/io/backend/skeleton/web/advanced/virtualthread/VirtualThreadProfileProperties.java` +- Test: `modules/web-advanced/web-advanced-testkit/src/test/java/io/backend/skeleton/web/advanced/virtualthread/VirtualThreadMvcLoadIT.java` + +**Interfaces:** +- Consumes: Stable MVC starter, admission control, Java 21 +- Produces: opt-in virtual-thread MVC executor with unchanged DB/outbound concurrency budgets + +**Implementation requirements:** +- Virtual Thread 활성화가 DB pool·HTTP bulkhead·admission 상한을 증가시키지 않는다. +- Pinned thread·native monitor 장기 점유를 JFR/metric으로 관측한다. +- Platform thread MVC와 동일한 HTTP wire contract를 통과한다. + +- [ ] **Step 1: Write the failing test** + +```java +@org.junit.jupiter.api.Tag("web-advanced-performance") +class VirtualThreadMvcLoadIT { + @org.junit.jupiter.api.Test + void virtualThreadsDoNotBypassAdmissionLimit() { + var result = VirtualThreadLoadFixture.run( + 2_000, + new VirtualThreadAdmissionLimit(100) + ); + org.junit.jupiter.api.Assertions.assertTrue(result.maxActiveUseCases() <= 100); + org.junit.jupiter.api.Assertions.assertTrue(result.virtualThreadCount() > 100); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the expected failure** + +Run: `./gradlew :modules:web-advanced:web-advanced-testkit:test --tests '*VirtualThreadMvcLoadIT'` + +Expected: FAIL because no virtual-thread MVC profile or admission guard exists. + +- [ ] **Step 3: Implement the minimum production contract** + +```java +@Configuration +@ConditionalOnProperty( + prefix = "backend.web.advanced.virtual-threads", + name = "enabled", + havingValue = "true" +) +public class VirtualThreadMvcConfiguration { + @Bean + java.util.concurrent.Executor webMvcVirtualThreadExecutor() { + return java.util.concurrent.Executors.newThreadPerTaskExecutor( + Thread.ofVirtual().name("web-mvc-vt-", 0).factory() + ); + } +} +``` + +- [ ] **Step 4: Run the task test and its module contract suite** + +Run: `./gradlew :modules:web-advanced:web-advanced-testkit:test --tests '*VirtualThreadMvcLoadIT'` + +Expected: PASS with no skipped contract assertions and no new warnings. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/web-advanced/web-advanced-bootstrap/src/main/java/io/backend/skeleton/web/advanced/virtualthread/VirtualThreadMvcConfiguration.java' 'modules/web-advanced/web-advanced-bootstrap/src/main/java/io/backend/skeleton/web/advanced/virtualthread/VirtualThreadAdmissionGuard.java' 'modules/web-advanced/web-advanced-bootstrap/src/main/java/io/backend/skeleton/web/advanced/virtualthread/VirtualThreadProfileProperties.java' 'modules/web-advanced/web-advanced-testkit/src/test/java/io/backend/skeleton/web/advanced/virtualthread/VirtualThreadMvcLoadIT.java' +git commit -m "feat(web): add bounded virtual-thread MVC profile" +``` + +### Task 3: WebFlux Controlled Blocking Bridge + +**Files:** +- Create: `modules/web-advanced/web-advanced-bootstrap/src/main/java/io/backend/skeleton/web/advanced/blockingbridge/BlockingBridgeProfile.java` +- Create: `modules/web-advanced/web-advanced-bootstrap/src/main/java/io/backend/skeleton/web/advanced/blockingbridge/ControlledBlockingBridge.java` +- Create: `modules/web-advanced/web-advanced-bootstrap/src/main/java/io/backend/skeleton/web/advanced/blockingbridge/BlockingBridgeBudget.java` +- Create: `modules/web-advanced/web-advanced-bootstrap/src/main/java/io/backend/skeleton/web/advanced/blockingbridge/BlockingBridgeRejectedException.java` +- Test: `modules/web-advanced/web-advanced-testkit/src/test/java/io/backend/skeleton/web/advanced/blockingbridge/ControlledBlockingBridgeIT.java` + +**Interfaces:** +- Consumes: Stable WebFlux event-loop guard and admission controller +- Produces: explicit bounded offload for registered blocking dependencies + +**Implementation requirements:** +- 등록된 dependency와 operation만 bridge를 사용할 수 있다. +- bounded concurrency, queue timeout, cancellation propagation을 강제한다. +- 일반 WebFlux Controller에서 임의 `boundedElastic()` 호출을 대체하지 않는다. + +- [ ] **Step 1: Write the failing test** + +```java +class ControlledBlockingBridgeIT { + @org.junit.jupiter.api.Test + void blockingWorkRunsOffEventLoopAndIsBounded() { + var bridge = ControlledBlockingBridge.fixture(4); + var result = bridge.execute("jpa.read", () -> Thread.currentThread().getName()).block(); + + org.junit.jupiter.api.Assertions.assertFalse(result.startsWith("reactor-http-")); + org.junit.jupiter.api.Assertions.assertTrue(bridge.maxObservedConcurrency() <= 4); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the expected failure** + +Run: `./gradlew :modules:web-advanced:web-advanced-testkit:test --tests '*ControlledBlockingBridgeIT'` + +Expected: FAIL because controlled blocking offload is unavailable. + +- [ ] **Step 3: Implement the minimum production contract** + +```java +public final class ControlledBlockingBridge { + private final reactor.core.scheduler.Scheduler scheduler; + private final java.util.concurrent.Semaphore permits; + + public reactor.core.publisher.Mono execute( + String registeredOperation, + java.util.concurrent.Callable work) { + return reactor.core.publisher.Mono.fromCallable(() -> { + if (!permits.tryAcquire()) { + throw new BlockingBridgeRejectedException(registeredOperation); + } + try { + return work.call(); + } finally { + permits.release(); + } + }) + .subscribeOn(scheduler); + } +} +``` + +- [ ] **Step 4: Run the task test and its module contract suite** + +Run: `./gradlew :modules:web-advanced:web-advanced-testkit:test` + +Expected: PASS with no skipped contract assertions and no new warnings. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/web-advanced/web-advanced-bootstrap/src/main/java/io/backend/skeleton/web/advanced/blockingbridge/BlockingBridgeProfile.java' 'modules/web-advanced/web-advanced-bootstrap/src/main/java/io/backend/skeleton/web/advanced/blockingbridge/ControlledBlockingBridge.java' 'modules/web-advanced/web-advanced-bootstrap/src/main/java/io/backend/skeleton/web/advanced/blockingbridge/BlockingBridgeBudget.java' 'modules/web-advanced/web-advanced-bootstrap/src/main/java/io/backend/skeleton/web/advanced/blockingbridge/BlockingBridgeRejectedException.java' 'modules/web-advanced/web-advanced-testkit/src/test/java/io/backend/skeleton/web/advanced/blockingbridge/ControlledBlockingBridgeIT.java' +git commit -m "feat(web): add controlled WebFlux blocking bridge" +``` + +### Task 4: JSON Merge Patch RFC 7396 + +**Files:** +- Create: `modules/web-advanced/web-patch/src/main/java/io/backend/skeleton/web/advanced/patch/JsonMergePatchDocument.java` +- Create: `modules/web-advanced/web-patch/src/main/java/io/backend/skeleton/web/advanced/patch/JsonMergePatchApplier.java` +- Create: `modules/web-advanced/web-patch/src/main/java/io/backend/skeleton/web/advanced/patch/PatchFieldAuthorization.java` +- Create: `modules/web-advanced/web-patch/src/main/java/io/backend/skeleton/web/advanced/patch/PatchResult.java` +- Test: `modules/web-advanced/web-patch/src/test/java/io/backend/skeleton/web/advanced/patch/JsonMergePatchApplierTest.java` + +**Interfaces:** +- Consumes: Stable strict JSON profile, If-Match precondition, DTO validation +- Produces: typed target merge-patch application with null-delete semantics and field authorization + +**Implementation requirements:** +- `application/merge-patch+json`만 처리한다. +- Patch 대상 field allowlist와 property authorization을 적용한다. +- 적용 결과 DTO를 다시 전체 validation하고 If-Match를 요구하는 mutation profile과 결합한다. + +- [ ] **Step 1: Write the failing test** + +```java +class JsonMergePatchApplierTest { + record Profile(String displayName, String description) {} + + @org.junit.jupiter.api.Test + void nullRemovesNullableFieldButCannotModifyForbiddenField() { + var applier = JsonMergePatchApplier.fixture( + PatchFieldAuthorization.allow("description") + ); + var result = applier.apply( + new Profile("Donghyeon", "old"), + "{\"description\":null}" + ); + org.junit.jupiter.api.Assertions.assertNull(result.description()); + + org.junit.jupiter.api.Assertions.assertThrows( + PatchAuthorizationException.class, + () -> applier.apply( + new Profile("Donghyeon", "old"), + "{\"displayName\":\"other\"}" + ) + ); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the expected failure** + +Run: `./gradlew :modules:web-advanced:web-patch:test --tests '*JsonMergePatchApplierTest'` + +Expected: FAIL because merge patch parsing and authorization are absent. + +- [ ] **Step 3: Implement the minimum production contract** + +```java +public final class JsonMergePatchApplier { + private final com.fasterxml.jackson.databind.ObjectMapper mapper; + private final PatchFieldAuthorization authorization; + + public T apply(T current, byte[] patch, Class type) { + var node = mapper.valueToTree(current); + var patchNode = readObject(patch); + authorization.validate(patchNode.fieldNames()); + var merged = merge(node, patchNode); + return mapper.treeToValue(merged, type); + } +} +``` + +- [ ] **Step 4: Run the task test and its module contract suite** + +Run: `./gradlew :modules:web-advanced:web-patch:test` + +Expected: PASS with no skipped contract assertions and no new warnings. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/web-advanced/web-patch/src/main/java/io/backend/skeleton/web/advanced/patch/JsonMergePatchDocument.java' 'modules/web-advanced/web-patch/src/main/java/io/backend/skeleton/web/advanced/patch/JsonMergePatchApplier.java' 'modules/web-advanced/web-patch/src/main/java/io/backend/skeleton/web/advanced/patch/PatchFieldAuthorization.java' 'modules/web-advanced/web-patch/src/main/java/io/backend/skeleton/web/advanced/patch/PatchResult.java' 'modules/web-advanced/web-patch/src/test/java/io/backend/skeleton/web/advanced/patch/JsonMergePatchApplierTest.java' +git commit -m "feat(web): add authorized JSON Merge Patch" +``` + +### Task 5: JSON Patch RFC 6902 + +**Files:** +- Create: `modules/web-advanced/web-patch/src/main/java/io/backend/skeleton/web/advanced/patch/JsonPatchDocument.java` +- Create: `modules/web-advanced/web-patch/src/main/java/io/backend/skeleton/web/advanced/patch/JsonPatchOperation.java` +- Create: `modules/web-advanced/web-patch/src/main/java/io/backend/skeleton/web/advanced/patch/JsonPatchApplier.java` +- Create: `modules/web-advanced/web-patch/src/main/java/io/backend/skeleton/web/advanced/patch/JsonPointerAuthorization.java` +- Test: `modules/web-advanced/web-patch/src/test/java/io/backend/skeleton/web/advanced/patch/JsonPatchApplierTest.java` + +**Interfaces:** +- Consumes: Task 4 Merge Patch policies and Stable precondition contracts +- Produces: ordered RFC 6902 add/remove/replace/test operations with pointer authorization and atomic validation + +**Implementation requirements:** +- `application/json-patch+json`만 처리한다. +- operation count와 pointer depth를 제한한다. +- `test` 실패 또는 authorization 실패 시 어떤 operation도 반영하지 않는다. + +- [ ] **Step 1: Write the failing test** + +```java +class JsonPatchApplierTest { + @org.junit.jupiter.api.Test + void failedTestOperationLeavesTargetUnchanged() { + var target = JsonPatchFixtures.profile("old"); + var patch = JsonPatchDocument.parse( + "[{\"op\":\"test\",\"path\":\"/version\",\"value\":2}," + + "{\"op\":\"replace\",\"path\":\"/displayName\",\"value\":\"new\"}]" + ); + + org.junit.jupiter.api.Assertions.assertThrows( + JsonPatchTestFailedException.class, + () -> JsonPatchApplier.fixture().apply(target, patch) + ); + org.junit.jupiter.api.Assertions.assertEquals("old", target.displayName()); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the expected failure** + +Run: `./gradlew :modules:web-advanced:web-patch:test --tests '*JsonPatchApplierTest'` + +Expected: FAIL because JSON Patch operations are unsupported. + +- [ ] **Step 3: Implement the minimum production contract** + +```java +public final class JsonPatchApplier { + public T apply(T current, JsonPatchDocument patch, Class type) { + if (patch.operations().size() > 100) { + throw new JsonPatchRejectedException("too many patch operations"); + } + var working = deepCopy(current); + for (var operation : patch.operations()) { + pointerAuthorization.validate(operation.path(), operation.op()); + working = operation.apply(working); + } + validator.validateOrThrow(working); + return mapper.treeToValue(working, type); + } +} +``` + +- [ ] **Step 4: Run the task test and its module contract suite** + +Run: `./gradlew :modules:web-advanced:web-patch:test` + +Expected: PASS with no skipped contract assertions and no new warnings. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/web-advanced/web-patch/src/main/java/io/backend/skeleton/web/advanced/patch/JsonPatchDocument.java' 'modules/web-advanced/web-patch/src/main/java/io/backend/skeleton/web/advanced/patch/JsonPatchOperation.java' 'modules/web-advanced/web-patch/src/main/java/io/backend/skeleton/web/advanced/patch/JsonPatchApplier.java' 'modules/web-advanced/web-patch/src/main/java/io/backend/skeleton/web/advanced/patch/JsonPointerAuthorization.java' 'modules/web-advanced/web-patch/src/test/java/io/backend/skeleton/web/advanced/patch/JsonPatchApplierTest.java' +git commit -m "feat(web): add atomic authorized JSON Patch" +``` + +### Task 6: Streaming Core Envelope와 Evidence + +**Files:** +- Create: `modules/web-advanced/web-streaming-core/src/main/java/io/backend/skeleton/web/advanced/stream/StreamId.java` +- Create: `modules/web-advanced/web-streaming-core/src/main/java/io/backend/skeleton/web/advanced/stream/StreamSequence.java` +- Create: `modules/web-advanced/web-streaming-core/src/main/java/io/backend/skeleton/web/advanced/stream/WebStreamEnvelope.java` +- Create: `modules/web-advanced/web-streaming-core/src/main/java/io/backend/skeleton/web/advanced/stream/WebStreamTerminal.java` +- Create: `modules/web-advanced/web-streaming-core/src/main/java/io/backend/skeleton/web/advanced/stream/WebStreamEvidence.java` +- Create: `modules/web-advanced/web-streaming-core/src/main/java/io/backend/skeleton/web/advanced/stream/WebStreamPolicy.java` +- Test: `modules/web-advanced/web-streaming-core/src/test/java/io/backend/skeleton/web/advanced/stream/WebStreamEnvelopeTest.java` + +**Interfaces:** +- Consumes: Stable response evidence and Messaging cursor concepts +- Produces: typed stream item/error/complete envelopes with sequence, heartbeat, partial-delivery evidence, and bounded policy + +**Implementation requirements:** +- Response headers committed 이후 HTTP status를 Problem으로 바꾸지 않는다. +- Normal completion, terminal application error, abrupt EOF를 구분한다. +- Durable replay history는 Streaming Core가 저장하지 않는다. + +- [ ] **Step 1: Write the failing test** + +```java +class WebStreamEnvelopeTest { + @org.junit.jupiter.api.Test + void sequenceMustIncreaseMonotonically() { + var tracker = new WebStreamEvidence(); + tracker.recordDelivered(new StreamSequence(1)); + org.junit.jupiter.api.Assertions.assertThrows( + IllegalStateException.class, + () -> tracker.recordDelivered(new StreamSequence(1)) + ); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the expected failure** + +Run: `./gradlew :modules:web-advanced:web-streaming-core:test --tests '*WebStreamEnvelopeTest'` + +Expected: FAIL because no streaming envelope or sequence evidence exists. + +- [ ] **Step 3: Implement the minimum production contract** + +```java +public sealed interface WebStreamEnvelope { + record Item( + StreamId streamId, + StreamSequence sequence, + T data + ) implements WebStreamEnvelope {} + + record Error( + StreamId streamId, + StreamSequence sequence, + ProblemCode code, + String message + ) implements WebStreamEnvelope {} + + record Complete( + StreamId streamId, + StreamSequence lastSequence + ) implements WebStreamEnvelope {} +} +``` + +- [ ] **Step 4: Run the task test and its module contract suite** + +Run: `./gradlew :modules:web-advanced:web-streaming-core:test` + +Expected: PASS with no skipped contract assertions and no new warnings. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/web-advanced/web-streaming-core/src/main/java/io/backend/skeleton/web/advanced/stream/StreamId.java' 'modules/web-advanced/web-streaming-core/src/main/java/io/backend/skeleton/web/advanced/stream/StreamSequence.java' 'modules/web-advanced/web-streaming-core/src/main/java/io/backend/skeleton/web/advanced/stream/WebStreamEnvelope.java' 'modules/web-advanced/web-streaming-core/src/main/java/io/backend/skeleton/web/advanced/stream/WebStreamTerminal.java' 'modules/web-advanced/web-streaming-core/src/main/java/io/backend/skeleton/web/advanced/stream/WebStreamEvidence.java' 'modules/web-advanced/web-streaming-core/src/main/java/io/backend/skeleton/web/advanced/stream/WebStreamPolicy.java' 'modules/web-advanced/web-streaming-core/src/test/java/io/backend/skeleton/web/advanced/stream/WebStreamEnvelopeTest.java' +git commit -m "feat(web): define bounded streaming envelopes" +``` + +### Task 7: MVC SSE Adapter + +**Files:** +- Create: `modules/web-advanced/web-streaming-mvc/src/main/java/io/backend/skeleton/web/advanced/stream/mvc/MvcSseSession.java` +- Create: `modules/web-advanced/web-streaming-mvc/src/main/java/io/backend/skeleton/web/advanced/stream/mvc/MvcSseEmitterFactory.java` +- Create: `modules/web-advanced/web-streaming-mvc/src/main/java/io/backend/skeleton/web/advanced/stream/mvc/MvcSseHeartbeatScheduler.java` +- Create: `modules/web-advanced/web-streaming-mvc/src/main/java/io/backend/skeleton/web/advanced/stream/mvc/MvcStreamingExecutorConfiguration.java` +- Test: `modules/web-advanced/web-advanced-testkit/src/test/java/io/backend/skeleton/web/advanced/stream/mvc/MvcSseContractIT.java` + +**Interfaces:** +- Consumes: Task 6 streaming core and Stable MVC evidence/context/admission +- Produces: production `SseEmitter` profile with dedicated executor, heartbeat, idle timeout, max age, and terminal events + +**Implementation requirements:** +- Spring 기본 async executor를 production에 사용하지 않는다. +- Heartbeat write failure를 client disconnect evidence로 기록한다. +- Per-connection buffer와 active session count를 bounded admission으로 제한한다. + +- [ ] **Step 1: Write the failing test** + +```java +class MvcSseContractIT { + @org.junit.jupiter.api.Test + void sendsHeartbeatAndTerminalCompleteEvent() { + var stream = MvcSseFixture.connect(); + org.junit.jupiter.api.Assertions.assertTrue(stream.awaitEvent("heartbeat")); + org.junit.jupiter.api.Assertions.assertTrue(stream.awaitEvent("complete")); + org.junit.jupiter.api.Assertions.assertTrue(stream.closed()); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the expected failure** + +Run: `./gradlew :modules:web-advanced:web-advanced-testkit:test --tests '*MvcSseContractIT'` + +Expected: FAIL because no production MVC SSE profile is configured. + +- [ ] **Step 3: Implement the minimum production contract** + +```java +public final class MvcSseEmitterFactory { + private final java.util.concurrent.Executor executor; + private final WebStreamPolicy policy; + + public org.springframework.web.servlet.mvc.method.annotation.SseEmitter create() { + var emitter = new org.springframework.web.servlet.mvc.method.annotation.SseEmitter( + policy.maxStreamAge().toMillis() + ); + heartbeatScheduler.register(emitter, policy.heartbeatInterval()); + return emitter; + } +} +``` + +- [ ] **Step 4: Run the task test and its module contract suite** + +Run: `./gradlew :modules:web-advanced:web-advanced-testkit:test` + +Expected: PASS with no skipped contract assertions and no new warnings. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/web-advanced/web-streaming-mvc/src/main/java/io/backend/skeleton/web/advanced/stream/mvc/MvcSseSession.java' 'modules/web-advanced/web-streaming-mvc/src/main/java/io/backend/skeleton/web/advanced/stream/mvc/MvcSseEmitterFactory.java' 'modules/web-advanced/web-streaming-mvc/src/main/java/io/backend/skeleton/web/advanced/stream/mvc/MvcSseHeartbeatScheduler.java' 'modules/web-advanced/web-streaming-mvc/src/main/java/io/backend/skeleton/web/advanced/stream/mvc/MvcStreamingExecutorConfiguration.java' 'modules/web-advanced/web-advanced-testkit/src/test/java/io/backend/skeleton/web/advanced/stream/mvc/MvcSseContractIT.java' +git commit -m "feat(web): add bounded MVC SSE adapter" +``` + +### Task 8: WebFlux SSE Adapter + +**Files:** +- Create: `modules/web-advanced/web-streaming-webflux/src/main/java/io/backend/skeleton/web/advanced/stream/webflux/WebFluxSseAdapter.java` +- Create: `modules/web-advanced/web-streaming-webflux/src/main/java/io/backend/skeleton/web/advanced/stream/webflux/WebFluxSseHeartbeat.java` +- Create: `modules/web-advanced/web-streaming-webflux/src/main/java/io/backend/skeleton/web/advanced/stream/webflux/WebFluxStreamAdmission.java` +- Test: `modules/web-advanced/web-advanced-testkit/src/test/java/io/backend/skeleton/web/advanced/stream/webflux/WebFluxSseContractIT.java` + +**Interfaces:** +- Consumes: Task 6 streaming core and Stable WebFlux Reactor context/admission +- Produces: `Flux>` adapter with bounded pending events, heartbeat, cancellation, and max age + +**Implementation requirements:** +- Backpressure가 source 전체를 자동 보호한다고 가정하지 않는다. +- Slow consumer에서 buffer가 상한에 도달하면 기본 정책은 connection close다. +- Cancellation을 Messaging source subscription까지 전파한다. + +- [ ] **Step 1: Write the failing test** + +```java +class WebFluxSseContractIT { + @org.junit.jupiter.api.Test + void slowConsumerIsClosedInsteadOfUnboundedBuffering() { + var fixture = WebFluxSseFixture.withBufferLimit(8); + reactor.test.StepVerifier.create(fixture.slowConsumerStream()) + .expectNextCount(8) + .expectError(SlowConsumerClosedException.class) + .verify(); + org.junit.jupiter.api.Assertions.assertTrue(fixture.maxBuffered() <= 8); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the expected failure** + +Run: `./gradlew :modules:web-advanced:web-advanced-testkit:test --tests '*WebFluxSseContractIT'` + +Expected: FAIL because bounded reactive SSE delivery is absent. + +- [ ] **Step 3: Implement the minimum production contract** + +```java +public final class WebFluxSseAdapter { + public reactor.core.publisher.Flux< + org.springframework.http.codec.ServerSentEvent>> adapt( + reactor.core.publisher.Flux> source, + WebStreamPolicy policy) { + return source + .onBackpressureBuffer( + policy.maxBufferedItems(), + ignored -> {}, + reactor.core.publisher.BufferOverflowStrategy.ERROR + ) + .mergeWith(heartbeat.events(policy.heartbeatInterval())) + .take(policy.maxStreamAge()); + } +} +``` + +- [ ] **Step 4: Run the task test and its module contract suite** + +Run: `./gradlew :modules:web-advanced:web-advanced-testkit:test` + +Expected: PASS with no skipped contract assertions and no new warnings. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/web-advanced/web-streaming-webflux/src/main/java/io/backend/skeleton/web/advanced/stream/webflux/WebFluxSseAdapter.java' 'modules/web-advanced/web-streaming-webflux/src/main/java/io/backend/skeleton/web/advanced/stream/webflux/WebFluxSseHeartbeat.java' 'modules/web-advanced/web-streaming-webflux/src/main/java/io/backend/skeleton/web/advanced/stream/webflux/WebFluxStreamAdmission.java' 'modules/web-advanced/web-advanced-testkit/src/test/java/io/backend/skeleton/web/advanced/stream/webflux/WebFluxSseContractIT.java' +git commit -m "feat(web): add bounded WebFlux SSE adapter" +``` + +### Task 9: NDJSON Streaming Adapter + +**Files:** +- Create: `modules/web-advanced/web-streaming-core/src/main/java/io/backend/skeleton/web/advanced/stream/ndjson/NdjsonRecord.java` +- Create: `modules/web-advanced/web-streaming-mvc/src/main/java/io/backend/skeleton/web/advanced/stream/mvc/MvcNdjsonWriter.java` +- Create: `modules/web-advanced/web-streaming-webflux/src/main/java/io/backend/skeleton/web/advanced/stream/webflux/WebFluxNdjsonWriter.java` +- Create: `modules/web-advanced/web-streaming-core/src/main/java/io/backend/skeleton/web/advanced/stream/ndjson/NdjsonMediaType.java` +- Test: `modules/web-advanced/web-advanced-testkit/src/test/java/io/backend/skeleton/web/advanced/stream/ndjson/NdjsonContractTest.java` + +**Interfaces:** +- Consumes: Task 6 streaming envelope, Tasks 7–8 stack streaming adapters +- Produces: `application/x-ndjson` records with one JSON value per line and terminal record profile + +**Implementation requirements:** +- 각 record는 독립적으로 strict JSON serialization된다. +- Line size와 total response budget을 적용한다. +- Abrupt EOF와 terminal complete record를 구분한다. + +- [ ] **Step 1: Write the failing test** + +```java +class NdjsonContractTest { + @org.junit.jupiter.api.Test + void everyRecordEndsWithOneNewlineAndCompleteMarker() { + var bytes = NdjsonFixture.write( + java.util.List.of( + NdjsonRecord.item(1, java.util.Map.of("id", "a")), + NdjsonRecord.complete(1) + ) + ); + var text = new String(bytes, java.nio.charset.StandardCharsets.UTF_8); + org.junit.jupiter.api.Assertions.assertTrue(text.endsWith("\n")); + org.junit.jupiter.api.Assertions.assertEquals(2, text.lines().count()); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the expected failure** + +Run: `./gradlew :modules:web-advanced:web-advanced-testkit:test --tests '*NdjsonContractTest'` + +Expected: FAIL because NDJSON media and writers are missing. + +- [ ] **Step 3: Implement the minimum production contract** + +```java +public sealed interface NdjsonRecord { + record Item(long sequence, T data) implements NdjsonRecord {} + record Error(long sequence, String code, String message) + implements NdjsonRecord {} + record Complete(long lastSequence) implements NdjsonRecord {} +} + +public final class WebFluxNdjsonWriter { + public reactor.core.publisher.Flux write( + reactor.core.publisher.Flux> records) { + return records.map(record -> buffers.wrap( + (mapper.writeValueAsString(record) + "\n") + .getBytes(java.nio.charset.StandardCharsets.UTF_8) + )); + } +} +``` + +- [ ] **Step 4: Run the task test and its module contract suite** + +Run: `./gradlew :modules:web-advanced:web-advanced-testkit:test` + +Expected: PASS with no skipped contract assertions and no new warnings. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/web-advanced/web-streaming-core/src/main/java/io/backend/skeleton/web/advanced/stream/ndjson/NdjsonRecord.java' 'modules/web-advanced/web-streaming-mvc/src/main/java/io/backend/skeleton/web/advanced/stream/mvc/MvcNdjsonWriter.java' 'modules/web-advanced/web-streaming-webflux/src/main/java/io/backend/skeleton/web/advanced/stream/webflux/WebFluxNdjsonWriter.java' 'modules/web-advanced/web-streaming-core/src/main/java/io/backend/skeleton/web/advanced/stream/ndjson/NdjsonMediaType.java' 'modules/web-advanced/web-advanced-testkit/src/test/java/io/backend/skeleton/web/advanced/stream/ndjson/NdjsonContractTest.java' +git commit -m "feat(web): add NDJSON streaming contract" +``` + +### Task 10: JSON Text Sequence Streaming Adapter + +**Files:** +- Create: `modules/web-advanced/web-streaming-core/src/main/java/io/backend/skeleton/web/advanced/stream/jsonseq/JsonSequenceEncoder.java` +- Create: `modules/web-advanced/web-streaming-core/src/main/java/io/backend/skeleton/web/advanced/stream/jsonseq/JsonSequenceMediaType.java` +- Create: `modules/web-advanced/web-streaming-mvc/src/main/java/io/backend/skeleton/web/advanced/stream/mvc/MvcJsonSequenceWriter.java` +- Create: `modules/web-advanced/web-streaming-webflux/src/main/java/io/backend/skeleton/web/advanced/stream/webflux/WebFluxJsonSequenceWriter.java` +- Test: `modules/web-advanced/web-advanced-testkit/src/test/java/io/backend/skeleton/web/advanced/stream/jsonseq/JsonSequenceContractTest.java` + +**Interfaces:** +- Consumes: Task 6 streaming envelope and strict JSON profile +- Produces: `application/json-seq` record-separator framed streaming for robust record boundaries + +**Implementation requirements:** +- 각 record 앞에 RS `0x1E`, 뒤에 LF를 쓴다. +- Malformed 또는 truncated record가 다음 record boundary를 오염시키지 않는다. +- Client support가 확인된 profile에만 활성화한다. + +- [ ] **Step 1: Write the failing test** + +```java +class JsonSequenceContractTest { + @org.junit.jupiter.api.Test + void framesEveryRecordWithRsAndLf() { + var encoded = new JsonSequenceEncoder(WebJsonFixtures.mapper()) + .encode(java.util.Map.of("id", "a")); + org.junit.jupiter.api.Assertions.assertEquals(0x1E, encoded[0]); + org.junit.jupiter.api.Assertions.assertEquals('\n', encoded[encoded.length - 1]); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the expected failure** + +Run: `./gradlew :modules:web-advanced:web-advanced-testkit:test --tests '*JsonSequenceContractTest'` + +Expected: FAIL because JSON Text Sequence framing is not implemented. + +- [ ] **Step 3: Implement the minimum production contract** + +```java +public final class JsonSequenceEncoder { + private static final byte RS = 0x1E; + private final com.fasterxml.jackson.databind.ObjectMapper mapper; + + public byte[] encode(Object value) { + var json = mapper.writeValueAsBytes(value); + var output = new byte[json.length + 2]; + output[0] = RS; + System.arraycopy(json, 0, output, 1, json.length); + output[output.length - 1] = '\n'; + return output; + } +} +``` + +- [ ] **Step 4: Run the task test and its module contract suite** + +Run: `./gradlew :modules:web-advanced:web-advanced-testkit:test` + +Expected: PASS with no skipped contract assertions and no new warnings. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/web-advanced/web-streaming-core/src/main/java/io/backend/skeleton/web/advanced/stream/jsonseq/JsonSequenceEncoder.java' 'modules/web-advanced/web-streaming-core/src/main/java/io/backend/skeleton/web/advanced/stream/jsonseq/JsonSequenceMediaType.java' 'modules/web-advanced/web-streaming-mvc/src/main/java/io/backend/skeleton/web/advanced/stream/mvc/MvcJsonSequenceWriter.java' 'modules/web-advanced/web-streaming-webflux/src/main/java/io/backend/skeleton/web/advanced/stream/webflux/WebFluxJsonSequenceWriter.java' 'modules/web-advanced/web-advanced-testkit/src/test/java/io/backend/skeleton/web/advanced/stream/jsonseq/JsonSequenceContractTest.java' +git commit -m "feat(web): add JSON Text Sequence streaming" +``` + +### Task 11: Commit 이후 Terminal Error·Partial Response 계약 + +**Files:** +- Create: `modules/web-advanced/web-streaming-core/src/main/java/io/backend/skeleton/web/advanced/stream/WebStreamErrorPolicy.java` +- Create: `modules/web-advanced/web-streaming-core/src/main/java/io/backend/skeleton/web/advanced/stream/WebStreamTermination.java` +- Create: `modules/web-advanced/web-streaming-core/src/main/java/io/backend/skeleton/web/advanced/stream/WebPartialResponseException.java` +- Create: `modules/web-advanced/web-streaming-core/src/main/java/io/backend/skeleton/web/advanced/stream/WebStreamTerminationMapper.java` +- Test: `modules/web-advanced/web-advanced-testkit/src/test/java/io/backend/skeleton/web/advanced/stream/WebStreamTerminationContractTest.java` + +**Interfaces:** +- Consumes: Tasks 6–10 stream envelopes and Stable response evidence +- Produces: typed terminal error when possible and explicit abrupt-close evidence when status can no longer change + +**Implementation requirements:** +- Headers commit 전 오류만 RFC 9457 HTTP error로 전환한다. +- Headers commit 후 SSE/NDJSON/JSON-seq는 protocol terminal error를 사용한다. +- Terminal frame도 쓸 수 없는 connection failure는 `ABRUPT_CLOSE`로 기록한다. + +- [ ] **Step 1: Write the failing test** + +```java +class WebStreamTerminationContractTest { + @org.junit.jupiter.api.Test + void committedResponseNeverAttemptsToChangeHttpStatus() { + var result = WebStreamTerminationMapper.fixture() + .mapFailure(true, new IllegalStateException("dependency failed")); + + org.junit.jupiter.api.Assertions.assertEquals( + WebStreamTermination.TERMINAL_ERROR_RECORD, + result.termination() + ); + org.junit.jupiter.api.Assertions.assertTrue(result.httpStatusChange().isEmpty()); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the expected failure** + +Run: `./gradlew :modules:web-advanced:web-advanced-testkit:test --tests '*WebStreamTerminationContractTest'` + +Expected: FAIL because committed-stream errors are not modeled. + +- [ ] **Step 3: Implement the minimum production contract** + +```java +public enum WebStreamTermination { + NORMAL_COMPLETE, + TERMINAL_ERROR_RECORD, + CLIENT_DISCONNECTED, + IDLE_TIMEOUT, + MAX_AGE, + ABRUPT_CLOSE +} + +public final class WebStreamTerminationMapper { + public TerminationDecision mapFailure(boolean responseCommitted, Throwable failure) { + if (!responseCommitted) { + return TerminationDecision.problem(WebProblemFactory.standard() + .internal(failure)); + } + return TerminationDecision.stream( + WebStreamTermination.TERMINAL_ERROR_RECORD, + SafeStreamError.from(failure) + ); + } +} +``` + +- [ ] **Step 4: Run the task test and its module contract suite** + +Run: `./gradlew :modules:web-advanced:web-advanced-testkit:test` + +Expected: PASS with no skipped contract assertions and no new warnings. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/web-advanced/web-streaming-core/src/main/java/io/backend/skeleton/web/advanced/stream/WebStreamErrorPolicy.java' 'modules/web-advanced/web-streaming-core/src/main/java/io/backend/skeleton/web/advanced/stream/WebStreamTermination.java' 'modules/web-advanced/web-streaming-core/src/main/java/io/backend/skeleton/web/advanced/stream/WebPartialResponseException.java' 'modules/web-advanced/web-streaming-core/src/main/java/io/backend/skeleton/web/advanced/stream/WebStreamTerminationMapper.java' 'modules/web-advanced/web-advanced-testkit/src/test/java/io/backend/skeleton/web/advanced/stream/WebStreamTerminationContractTest.java' +git commit -m "feat(web): define post-commit stream error semantics" +``` + +### Task 12: Client Disconnect·Slow Consumer·Shutdown Drain + +**Files:** +- Create: `modules/web-advanced/web-streaming-core/src/main/java/io/backend/skeleton/web/advanced/stream/WebStreamRegistry.java` +- Create: `modules/web-advanced/web-streaming-core/src/main/java/io/backend/skeleton/web/advanced/stream/WebStreamSession.java` +- Create: `modules/web-advanced/web-streaming-core/src/main/java/io/backend/skeleton/web/advanced/stream/WebStreamDrainCoordinator.java` +- Create: `modules/web-advanced/web-streaming-mvc/src/main/java/io/backend/skeleton/web/advanced/stream/mvc/MvcDisconnectDetector.java` +- Create: `modules/web-advanced/web-streaming-webflux/src/main/java/io/backend/skeleton/web/advanced/stream/webflux/WebFluxDisconnectDetector.java` +- Test: `modules/web-advanced/web-advanced-testkit/src/test/java/io/backend/skeleton/web/advanced/stream/WebStreamDrainIT.java` + +**Interfaces:** +- Consumes: Tasks 7–11 stack streaming and terminal policies +- Produces: active-session registry, disconnect evidence, bounded slow-consumer policy, readiness drain, and forced close deadline + +**Implementation requirements:** +- Shutdown 시 readiness를 먼저 내리고 신규 stream을 거부한다. +- 기존 stream에 terminal/reconnect hint를 보낸 뒤 deadline까지 drain한다. +- MVC는 heartbeat write failure, WebFlux는 cancellation signal로 disconnect를 감지한다. + +- [ ] **Step 1: Write the failing test** + +```java +class WebStreamDrainIT { + @org.junit.jupiter.api.Test + void shutdownRejectsNewStreamsAndDrainsExistingOnes() { + var fixture = WebStreamDrainFixture.twoActiveStreams(); + fixture.beginShutdown(); + + org.junit.jupiter.api.Assertions.assertFalse(fixture.acceptsNewStream()); + fixture.awaitDrain(java.time.Duration.ofSeconds(5)); + org.junit.jupiter.api.Assertions.assertEquals(0, fixture.activeStreams()); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the expected failure** + +Run: `./gradlew :modules:web-advanced:web-advanced-testkit:test --tests '*WebStreamDrainIT'` + +Expected: FAIL because no stream registry or drain coordinator exists. + +- [ ] **Step 3: Implement the minimum production contract** + +```java +public final class WebStreamDrainCoordinator { + private final WebStreamRegistry registry; + private final java.util.concurrent.atomic.AtomicBoolean accepting = + new java.util.concurrent.atomic.AtomicBoolean(true); + + public void beginDrain(java.time.Duration timeout) { + accepting.set(false); + registry.sessions().forEach(WebStreamSession::requestReconnect); + registry.awaitEmpty(timeout); + registry.forceCloseRemaining(); + } + + public boolean accepting() { + return accepting.get(); + } +} +``` + +- [ ] **Step 4: Run the task test and its module contract suite** + +Run: `./gradlew :modules:web-advanced:web-advanced-testkit:test` + +Expected: PASS with no skipped contract assertions and no new warnings. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/web-advanced/web-streaming-core/src/main/java/io/backend/skeleton/web/advanced/stream/WebStreamRegistry.java' 'modules/web-advanced/web-streaming-core/src/main/java/io/backend/skeleton/web/advanced/stream/WebStreamSession.java' 'modules/web-advanced/web-streaming-core/src/main/java/io/backend/skeleton/web/advanced/stream/WebStreamDrainCoordinator.java' 'modules/web-advanced/web-streaming-mvc/src/main/java/io/backend/skeleton/web/advanced/stream/mvc/MvcDisconnectDetector.java' 'modules/web-advanced/web-streaming-webflux/src/main/java/io/backend/skeleton/web/advanced/stream/webflux/WebFluxDisconnectDetector.java' 'modules/web-advanced/web-advanced-testkit/src/test/java/io/backend/skeleton/web/advanced/stream/WebStreamDrainIT.java' +git commit -m "feat(web): add stream disconnect and graceful drain control" +``` + +### Task 13: Messaging-backed SSE Replay·Last-Event-ID Bridge + +**Files:** +- Create: `modules/web-advanced/web-streaming-core/src/main/java/io/backend/skeleton/web/advanced/stream/replay/WebStreamResumeCursor.java` +- Create: `modules/web-advanced/web-streaming-core/src/main/java/io/backend/skeleton/web/advanced/stream/replay/WebStreamReplaySource.java` +- Create: `modules/web-advanced/web-streaming-core/src/main/java/io/backend/skeleton/web/advanced/stream/replay/MessagingReplayBridge.java` +- Create: `modules/web-advanced/web-streaming-core/src/main/java/io/backend/skeleton/web/advanced/stream/replay/ReplayCursorExpiredException.java` +- Test: `modules/web-advanced/web-advanced-testkit/src/test/java/io/backend/skeleton/web/advanced/stream/replay/MessagingReplayBridgeIT.java` + +**Interfaces:** +- Consumes: Stable Messaging replay API, Task 6 sequence envelope, Tasks 7–8 SSE +- Produces: mapping of Last-Event-ID to durable Messaging/Event Log cursor and snapshot+live recovery + +**Implementation requirements:** +- Web module이 durable event history를 저장하지 않는다. +- Expired cursor는 silent skip이 아니라 resnapshot-required response를 낸다. +- Replay와 live 전환 지점에서 duplicate/gap을 sequence로 검출한다. + +- [ ] **Step 1: Write the failing test** + +```java +class MessagingReplayBridgeIT { + @org.junit.jupiter.api.Test + void expiredCursorRequiresResnapshot() { + var bridge = MessagingReplayBridge.fixtureWithExpiredCursor("41"); + + org.junit.jupiter.api.Assertions.assertThrows( + ReplayCursorExpiredException.class, + () -> bridge.resume(new WebStreamResumeCursor("41")) + ); + } + + @org.junit.jupiter.api.Test + void replayThenLiveHasNoGapOrDuplicate() { + var events = MessagingReplayBridge.fixture() + .resume(new WebStreamResumeCursor("41")) + .take(3) + .collectList() + .block(); + org.junit.jupiter.api.Assertions.assertEquals( + java.util.List.of(42L, 43L, 44L), + events.stream().map(WebStreamEnvelope.Item::sequence).map(StreamSequence::value).toList() + ); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the expected failure** + +Run: `./gradlew :modules:web-advanced:web-advanced-testkit:test --tests '*MessagingReplayBridgeIT'` + +Expected: FAIL because SSE resume is not connected to a durable messaging source. + +- [ ] **Step 3: Implement the minimum production contract** + +```java +public final class MessagingReplayBridge { + private final ReplayableEventSource source; + + public reactor.core.publisher.Flux> resume( + WebStreamResumeCursor cursor) { + var replay = source.replayAfter(cursor.value()); + var live = source.liveAfter(replay.snapshotBoundary()); + return reactor.core.publisher.Flux.concat( + replay.events(), + live + ) + .transform(new GapAndDuplicateGuard<>()); + } +} +``` + +- [ ] **Step 4: Run the task test and its module contract suite** + +Run: `./gradlew :modules:web-advanced:web-advanced-testkit:test` + +Expected: PASS with no skipped contract assertions and no new warnings. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/web-advanced/web-streaming-core/src/main/java/io/backend/skeleton/web/advanced/stream/replay/WebStreamResumeCursor.java' 'modules/web-advanced/web-streaming-core/src/main/java/io/backend/skeleton/web/advanced/stream/replay/WebStreamReplaySource.java' 'modules/web-advanced/web-streaming-core/src/main/java/io/backend/skeleton/web/advanced/stream/replay/MessagingReplayBridge.java' 'modules/web-advanced/web-streaming-core/src/main/java/io/backend/skeleton/web/advanced/stream/replay/ReplayCursorExpiredException.java' 'modules/web-advanced/web-advanced-testkit/src/test/java/io/backend/skeleton/web/advanced/stream/replay/MessagingReplayBridgeIT.java' +git commit -m "feat(web): bridge SSE resume to durable messaging replay" +``` + +### Task 14: Functional WebFlux Endpoint Adapter + +**Files:** +- Create: `modules/web-advanced/web-functional-webflux/src/main/java/io/backend/skeleton/web/advanced/functional/RegisteredRouterFunction.java` +- Create: `modules/web-advanced/web-functional-webflux/src/main/java/io/backend/skeleton/web/advanced/functional/WebFunctionalRouteRegistry.java` +- Create: `modules/web-advanced/web-functional-webflux/src/main/java/io/backend/skeleton/web/advanced/functional/WebFunctionalHandlerAdapter.java` +- Create: `modules/web-advanced/web-functional-webflux/src/main/java/io/backend/skeleton/web/advanced/functional/FunctionalRoutePolicyValidator.java` +- Test: `modules/web-advanced/web-functional-webflux/src/test/java/io/backend/skeleton/web/advanced/functional/WebFunctionalRouteRegistryTest.java` + +**Interfaces:** +- Consumes: Stable operation catalog, request context, error, versioning, budget, and WebFlux stack +- Produces: functional endpoint registration that preserves the same W1/W2 route contract and guardrails + +**Implementation requirements:** +- Raw `RouterFunction` bean을 operation policy 없이 등록하지 않는다. +- Functional handler도 Application Use Case Adapter이며 Repository를 직접 호출하지 않는다. +- Annotated Controller와 동일한 Problem, security, budget, idempotency, observability를 사용한다. + +- [ ] **Step 1: Write the failing test** + +```java +class WebFunctionalRouteRegistryTest { + @org.junit.jupiter.api.Test + void rejectsRouteWithoutRegisteredOperationProfile() { + var registry = WebFunctionalRouteRegistry.fixture(); + org.junit.jupiter.api.Assertions.assertThrows( + IllegalStateException.class, + () -> registry.register( + RegisteredRouterFunction.get( + "/api/v1/items/{id}", + new WebOperationName("unregistered.operation"), + request -> org.springframework.web.reactive.function.server.ServerResponse.ok().build() + ) + ) + ); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the expected failure** + +Run: `./gradlew :modules:web-advanced:web-functional-webflux:test --tests '*WebFunctionalRouteRegistryTest'` + +Expected: FAIL because functional routes can bypass operation registration. + +- [ ] **Step 3: Implement the minimum production contract** + +```java +public final class WebFunctionalRouteRegistry { + private final WebOperationCatalog operations; + private final java.util.List routes = new java.util.ArrayList<>(); + + public void register(RegisteredRouterFunction route) { + operations.require(route.operationName()); + policyValidator.validate(route); + routes.add(route); + } + + public org.springframework.web.reactive.function.server.RouterFunction< + org.springframework.web.reactive.function.server.ServerResponse> build() { + return routes.stream() + .map(RegisteredRouterFunction::router) + .reduce(org.springframework.web.reactive.function.server.RouterFunctions::nest) + .orElseThrow(); + } +} +``` + +- [ ] **Step 4: Run the task test and its module contract suite** + +Run: `./gradlew :modules:web-advanced:web-functional-webflux:test` + +Expected: PASS with no skipped contract assertions and no new warnings. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/web-advanced/web-functional-webflux/src/main/java/io/backend/skeleton/web/advanced/functional/RegisteredRouterFunction.java' 'modules/web-advanced/web-functional-webflux/src/main/java/io/backend/skeleton/web/advanced/functional/WebFunctionalRouteRegistry.java' 'modules/web-advanced/web-functional-webflux/src/main/java/io/backend/skeleton/web/advanced/functional/WebFunctionalHandlerAdapter.java' 'modules/web-advanced/web-functional-webflux/src/main/java/io/backend/skeleton/web/advanced/functional/FunctionalRoutePolicyValidator.java' 'modules/web-advanced/web-functional-webflux/src/test/java/io/backend/skeleton/web/advanced/functional/WebFunctionalRouteRegistryTest.java' +git commit -m "feat(web): add policy-bound functional WebFlux endpoints" +``` + +### Task 15: CBOR Optional Codec Profile + +**Files:** +- Create: `modules/web-advanced/web-codec-cbor/src/main/java/io/backend/skeleton/web/advanced/codec/cbor/WebCborProfile.java` +- Create: `modules/web-advanced/web-codec-cbor/src/main/java/io/backend/skeleton/web/advanced/codec/cbor/WebCborMapperFactory.java` +- Create: `modules/web-advanced/web-codec-cbor/src/main/java/io/backend/skeleton/web/advanced/codec/cbor/CborContentNegotiationPolicy.java` +- Test: `modules/web-advanced/web-codec-cbor/src/test/java/io/backend/skeleton/web/advanced/codec/cbor/WebCborProfileTest.java` + +**Interfaces:** +- Consumes: Stable wire type manifest, budgets, and content negotiation +- Produces: `application/cbor` opt-in representation with the same DTO, validation, limits, and Problem semantics + +**Implementation requirements:** +- CBOR profile이 JSON profile보다 넓은 polymorphic/type coercion을 허용하지 않는다. +- Decoded object budget을 별도로 검증한다. +- Client allowlist와 route `produces/consumes` profile이 모두 허용할 때만 협상한다. + +- [ ] **Step 1: Write the failing test** + +```java +class WebCborProfileTest { + record Value(java.util.UUID id, java.time.Instant createdAt) {} + + @org.junit.jupiter.api.Test + void cborRoundTripPreservesWireTypes() { + var mapper = WebCborMapperFactory.standard(); + var value = new Value( + java.util.UUID.fromString("00000000-0000-0000-0000-000000000001"), + java.time.Instant.parse("2026-08-13T00:00:00Z") + ); + var restored = mapper.readValue(mapper.writeValueAsBytes(value), Value.class); + org.junit.jupiter.api.Assertions.assertEquals(value, restored); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the expected failure** + +Run: `./gradlew :modules:web-advanced:web-codec-cbor:test --tests '*WebCborProfileTest'` + +Expected: FAIL because no CBOR profile is available. + +- [ ] **Step 3: Implement the minimum production contract** + +```java +public final class WebCborMapperFactory { + public static com.fasterxml.jackson.databind.ObjectMapper standard() { + var factory = new com.fasterxml.jackson.dataformat.cbor.CBORFactory(); + return com.fasterxml.jackson.databind.json.JsonMapper.builder(factory) + .enable(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .disable(com.fasterxml.jackson.databind.MapperFeature.ALLOW_COERCION_OF_SCALARS) + .build(); + } +} +``` + +- [ ] **Step 4: Run the task test and its module contract suite** + +Run: `./gradlew :modules:web-advanced:web-codec-cbor:test` + +Expected: PASS with no skipped contract assertions and no new warnings. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/web-advanced/web-codec-cbor/src/main/java/io/backend/skeleton/web/advanced/codec/cbor/WebCborProfile.java' 'modules/web-advanced/web-codec-cbor/src/main/java/io/backend/skeleton/web/advanced/codec/cbor/WebCborMapperFactory.java' 'modules/web-advanced/web-codec-cbor/src/main/java/io/backend/skeleton/web/advanced/codec/cbor/CborContentNegotiationPolicy.java' 'modules/web-advanced/web-codec-cbor/src/test/java/io/backend/skeleton/web/advanced/codec/cbor/WebCborProfileTest.java' +git commit -m "feat(web): add optional bounded CBOR codec" +``` + +### Task 16: XML Optional Codec Profile + +**Files:** +- Create: `modules/web-advanced/web-codec-xml/src/main/java/io/backend/skeleton/web/advanced/codec/xml/WebXmlProfile.java` +- Create: `modules/web-advanced/web-codec-xml/src/main/java/io/backend/skeleton/web/advanced/codec/xml/WebXmlMapperFactory.java` +- Create: `modules/web-advanced/web-codec-xml/src/main/java/io/backend/skeleton/web/advanced/codec/xml/SecureXmlInputFactory.java` +- Create: `modules/web-advanced/web-codec-xml/src/main/java/io/backend/skeleton/web/advanced/codec/xml/XmlContentNegotiationPolicy.java` +- Test: `modules/web-advanced/web-codec-xml/src/test/java/io/backend/skeleton/web/advanced/codec/xml/WebXmlSecurityTest.java` + +**Interfaces:** +- Consumes: Stable wire type manifest, budgets, and content negotiation +- Produces: `application/xml` opt-in codec with DTD/external entity disabled and bounded depth/size + +**Implementation requirements:** +- XXE와 external entity resolution을 비활성화한다. +- XML body logging을 기본 비활성화한다. +- JSON과 동일한 request DTO·validation·Problem catalog를 사용한다. + +- [ ] **Step 1: Write the failing test** + +```java +class WebXmlSecurityTest { + @org.junit.jupiter.api.Test + void rejectsExternalEntity() { + var mapper = WebXmlMapperFactory.standard(); + var xml = """]> + &secret;"""; + + org.junit.jupiter.api.Assertions.assertThrows( + Exception.class, + () -> mapper.readValue(xml, XmlRequest.class) + ); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the expected failure** + +Run: `./gradlew :modules:web-advanced:web-codec-xml:test --tests '*WebXmlSecurityTest'` + +Expected: FAIL because secure XML parsing is not configured. + +- [ ] **Step 3: Implement the minimum production contract** + +```java +public final class SecureXmlInputFactory { + public static javax.xml.stream.XMLInputFactory create() { + var factory = javax.xml.stream.XMLInputFactory.newFactory(); + factory.setProperty(javax.xml.stream.XMLInputFactory.SUPPORT_DTD, false); + factory.setProperty("javax.xml.stream.isSupportingExternalEntities", false); + factory.setXMLResolver((publicID, systemID, baseURI, namespace) -> { + throw new javax.xml.stream.XMLStreamException("external entities disabled"); + }); + return factory; + } +} +``` + +- [ ] **Step 4: Run the task test and its module contract suite** + +Run: `./gradlew :modules:web-advanced:web-codec-xml:test` + +Expected: PASS with no skipped contract assertions and no new warnings. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/web-advanced/web-codec-xml/src/main/java/io/backend/skeleton/web/advanced/codec/xml/WebXmlProfile.java' 'modules/web-advanced/web-codec-xml/src/main/java/io/backend/skeleton/web/advanced/codec/xml/WebXmlMapperFactory.java' 'modules/web-advanced/web-codec-xml/src/main/java/io/backend/skeleton/web/advanced/codec/xml/SecureXmlInputFactory.java' 'modules/web-advanced/web-codec-xml/src/main/java/io/backend/skeleton/web/advanced/codec/xml/XmlContentNegotiationPolicy.java' 'modules/web-advanced/web-codec-xml/src/test/java/io/backend/skeleton/web/advanced/codec/xml/WebXmlSecurityTest.java' +git commit -m "feat(web): add secure optional XML codec" +``` + +### Task 17: OpenAPI 3.2 Experimental Compatibility Lane + +**Files:** +- Create: `modules/web-advanced/web-openapi-32-experimental/src/main/java/io/backend/skeleton/web/advanced/openapi32/WebOpenApi32Generator.java` +- Create: `modules/web-advanced/web-openapi-32-experimental/src/main/java/io/backend/skeleton/web/advanced/openapi32/WebOpenApi32CompatibilityReport.java` +- Create: `modules/web-advanced/web-openapi-32-experimental/src/main/java/io/backend/skeleton/web/advanced/openapi32/OpenApi32ToolchainMatrix.java` +- Create: `.github/workflows/web-openapi-32-experimental.yml` +- Test: `modules/web-advanced/web-openapi-32-experimental/src/test/java/io/backend/skeleton/web/advanced/openapi32/WebOpenApi32CompatibilityTest.java` + +**Interfaces:** +- Consumes: Stable OpenAPI 3.1.2 snapshot and Advanced streaming schemas +- Produces: parallel OpenAPI 3.2 generation and parser/generator/client compatibility report without replacing Stable artifact + +**Implementation requirements:** +- 3.1.2 snapshot은 계속 release source of truth다. +- 3.2 output은 parser, linter, generator, client compile matrix를 통과해도 promotion ADR 전에는 Experimental이다. +- Streaming description 차이를 별도 report로 남긴다. + +- [ ] **Step 1: Write the failing test** + +```java +class WebOpenApi32CompatibilityTest { + @org.junit.jupiter.api.Test + void generates32WithoutChangingStable312Snapshot() { + var stableHashBefore = StableOpenApiSnapshot.hash(); + var experimental = new WebOpenApi32Generator().generate(); + + org.junit.jupiter.api.Assertions.assertEquals("3.2.0", experimental.getOpenapi()); + org.junit.jupiter.api.Assertions.assertEquals( + stableHashBefore, + StableOpenApiSnapshot.hash() + ); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the expected failure** + +Run: `./gradlew :modules:web-advanced:web-openapi-32-experimental:test --tests '*WebOpenApi32CompatibilityTest'` + +Expected: FAIL because no isolated OpenAPI 3.2 compatibility lane exists. + +- [ ] **Step 3: Implement the minimum production contract** + +```java +public final class WebOpenApi32Generator { + public io.swagger.v3.oas.models.OpenAPI generate() { + var document = StableOpenApiModel.copy(); + document.setOpenapi("3.2.0"); + StreamingSchema32Contributor.apply(document); + return document; + } +} +``` + +- [ ] **Step 4: Run the task test and its module contract suite** + +Run: `./gradlew :modules:web-advanced:web-openapi-32-experimental:test` + +Expected: PASS with no skipped contract assertions and no new warnings. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/web-advanced/web-openapi-32-experimental/src/main/java/io/backend/skeleton/web/advanced/openapi32/WebOpenApi32Generator.java' 'modules/web-advanced/web-openapi-32-experimental/src/main/java/io/backend/skeleton/web/advanced/openapi32/WebOpenApi32CompatibilityReport.java' 'modules/web-advanced/web-openapi-32-experimental/src/main/java/io/backend/skeleton/web/advanced/openapi32/OpenApi32ToolchainMatrix.java' '.github/workflows/web-openapi-32-experimental.yml' 'modules/web-advanced/web-openapi-32-experimental/src/test/java/io/backend/skeleton/web/advanced/openapi32/WebOpenApi32CompatibilityTest.java' +git commit -m "build(web): add OpenAPI 3.2 experimental compatibility lane" +``` + +### Task 18: RateLimit Draft Header Experimental Profile + +**Files:** +- Create: `modules/web-advanced/web-ratelimit-draft-experimental/src/main/java/io/backend/skeleton/web/advanced/ratelimit/WebRateLimitDraftHeaderWriter.java` +- Create: `modules/web-advanced/web-ratelimit-draft-experimental/src/main/java/io/backend/skeleton/web/advanced/ratelimit/WebRateLimitDraftProfile.java` +- Create: `modules/web-advanced/web-ratelimit-draft-experimental/src/main/java/io/backend/skeleton/web/advanced/ratelimit/RateLimitDraftVersion.java` +- Test: `modules/web-advanced/web-ratelimit-draft-experimental/src/test/java/io/backend/skeleton/web/advanced/ratelimit/WebRateLimitDraftHeaderWriterTest.java` + +**Interfaces:** +- Consumes: Stable RateLimitDecision and 429/Retry-After mapping +- Produces: opt-in draft-versioned RateLimit and RateLimit-Policy headers + +**Implementation requirements:** +- `Retry-After`와 429는 Stable이며 draft headers는 부가 정보다. +- Draft version을 response/test artifact에 기록한다. +- Draft 변경 시 Stable API를 변경하지 않고 experimental module만 갱신한다. + +- [ ] **Step 1: Write the failing test** + +```java +class WebRateLimitDraftHeaderWriterTest { + @org.junit.jupiter.api.Test + void writesDraftHeadersOnlyWhenExplicitlyEnabled() { + var writer = new WebRateLimitDraftHeaderWriter( + WebRateLimitDraftProfile.enabled(RateLimitDraftVersion.DRAFT_11) + ); + var headers = writer.write(RateLimitFixtures.denied()); + + org.junit.jupiter.api.Assertions.assertTrue(headers.containsKey("RateLimit")); + org.junit.jupiter.api.Assertions.assertTrue(headers.containsKey("RateLimit-Policy")); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the expected failure** + +Run: `./gradlew :modules:web-advanced:web-ratelimit-draft-experimental:test --tests '*WebRateLimitDraftHeaderWriterTest'` + +Expected: FAIL because draft headers are intentionally absent. + +- [ ] **Step 3: Implement the minimum production contract** + +```java +public final class WebRateLimitDraftHeaderWriter { + public java.util.Map write(RateLimitDecision decision) { + if (!profile.enabled()) { + return java.util.Map.of(); + } + return java.util.Map.of( + "RateLimit", + "limit=" + decision.limit() + + ", remaining=" + decision.remaining() + + ", reset=" + secondsUntil(decision.resetAt()), + "RateLimit-Policy", + "q=" + decision.limit() + ";w=" + profile.windowSeconds() + ); + } +} +``` + +- [ ] **Step 4: Run the task test and its module contract suite** + +Run: `./gradlew :modules:web-advanced:web-ratelimit-draft-experimental:test` + +Expected: PASS with no skipped contract assertions and no new warnings. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/web-advanced/web-ratelimit-draft-experimental/src/main/java/io/backend/skeleton/web/advanced/ratelimit/WebRateLimitDraftHeaderWriter.java' 'modules/web-advanced/web-ratelimit-draft-experimental/src/main/java/io/backend/skeleton/web/advanced/ratelimit/WebRateLimitDraftProfile.java' 'modules/web-advanced/web-ratelimit-draft-experimental/src/main/java/io/backend/skeleton/web/advanced/ratelimit/RateLimitDraftVersion.java' 'modules/web-advanced/web-ratelimit-draft-experimental/src/test/java/io/backend/skeleton/web/advanced/ratelimit/WebRateLimitDraftHeaderWriterTest.java' +git commit -m "feat(web): add experimental RateLimit draft headers" +``` + +### Task 19: Advanced Cross-capability Soak·Promotion·Rollback Gate + +**Files:** +- Create: `modules/web-advanced/web-advanced-testkit/src/test/java/io/backend/skeleton/web/advanced/release/WebAdvancedPromotionGateTest.java` +- Create: `modules/web-advanced/web-advanced-testkit/src/test/java/io/backend/skeleton/web/advanced/release/WebStreamingSoakIT.java` +- Create: `modules/web-advanced/web-advanced-testkit/src/test/java/io/backend/skeleton/web/advanced/release/WebAdvancedRollbackIT.java` +- Create: `.github/workflows/web-advanced-nightly.yml` +- Create: `.github/workflows/web-advanced-release.yml` +- Create: `docs/web/advanced-capabilities.md` +- Create: `docs/web/streaming-contract.md` +- Create: `docs/web/patch-contract.md` +- Create: `docs/web/virtual-thread-profile.md` +- Create: `docs/web/openapi-32-compatibility.md` +- Create: `docs/adr/ADR-WEB-ADV-001-streaming-is-live-delivery.md` +- Create: `docs/adr/ADR-WEB-ADV-002-virtual-threads-do-not-remove-admission.md` +- Create: `docs/adr/ADR-WEB-ADV-003-openapi-32-remains-experimental.md` +- Modify: `build.gradle.kts` +- Test: `modules/web-advanced/web-advanced-testkit/src/test/java/io/backend/skeleton/web/advanced/release/WebAdvancedReleaseManifestTest.java` + +**Interfaces:** +- Consumes: Advanced Tasks 1–18 and Stable release artifacts +- Produces: nightly/release workflows, 1k→10k connection soak, slow-consumer, cancellation, patch security, codec security, rollback, and promotion evidence + +**Implementation requirements:** +- Advanced feature가 꺼졌을 때 Stable behavior가 동일한지 rollback test를 실행한다. +- Streaming은 connection count, buffer, heap, cancellation, source restart, pod drain을 검증한다. +- Promotion에는 production-like soak, security review, support matrix, ADR가 필요하다. + +- [ ] **Step 1: Write the failing test** + +```java +class WebAdvancedReleaseManifestTest { + @org.junit.jupiter.api.Test + void promotionEvidenceIsComplete() { + var manifest = WebAdvancedReleaseManifest.load(); + org.junit.jupiter.api.Assertions.assertTrue(manifest.has("stable-release-baseline")); + org.junit.jupiter.api.Assertions.assertTrue(manifest.has("streaming-soak-10k")); + org.junit.jupiter.api.Assertions.assertTrue(manifest.has("slow-consumer-bounded")); + org.junit.jupiter.api.Assertions.assertTrue(manifest.has("patch-security")); + org.junit.jupiter.api.Assertions.assertTrue(manifest.has("rollback-disabled-profile")); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the expected failure** + +Run: `./gradlew webAdvancedNightly webAdvancedRelease` + +Expected: FAIL because advanced soak, rollback, documentation, and promotion evidence are missing. + +- [ ] **Step 3: Implement the minimum production contract** + +```kotlin +tasks.register("webAdvancedNightly") { + useJUnitPlatform { + includeTags("web-advanced-nightly") + } +} + +tasks.register("webAdvancedRelease") { + useJUnitPlatform { + includeTags("web-advanced-release") + } + dependsOn("webStableCheck") + shouldRunAfter("webAdvancedNightly") +} +``` + +- [ ] **Step 4: Run the task test and its module contract suite** + +Run: `./gradlew webStableCheck webAdvancedNightly webAdvancedRelease` + +Expected: PASS with no skipped contract assertions and no new warnings. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/web-advanced/web-advanced-testkit/src/test/java/io/backend/skeleton/web/advanced/release/WebAdvancedPromotionGateTest.java' 'modules/web-advanced/web-advanced-testkit/src/test/java/io/backend/skeleton/web/advanced/release/WebStreamingSoakIT.java' 'modules/web-advanced/web-advanced-testkit/src/test/java/io/backend/skeleton/web/advanced/release/WebAdvancedRollbackIT.java' '.github/workflows/web-advanced-nightly.yml' '.github/workflows/web-advanced-release.yml' 'docs/web/advanced-capabilities.md' 'docs/web/streaming-contract.md' 'docs/web/patch-contract.md' 'docs/web/virtual-thread-profile.md' 'docs/web/openapi-32-compatibility.md' 'docs/adr/ADR-WEB-ADV-001-streaming-is-live-delivery.md' 'docs/adr/ADR-WEB-ADV-002-virtual-threads-do-not-remove-admission.md' 'docs/adr/ADR-WEB-ADV-003-openapi-32-remains-experimental.md' 'build.gradle.kts' 'modules/web-advanced/web-advanced-testkit/src/test/java/io/backend/skeleton/web/advanced/release/WebAdvancedReleaseManifestTest.java' +git commit -m "docs(web): add advanced promotion and rollback gates" +``` + +## 3. Advanced 계획 검증 체크리스트 + +- [ ] Task 1~19 번호가 연속적이다. +- [ ] 모든 Task가 exact files, interfaces, failing test, expected failure, implementation, pass command, commit을 포함한다. +- [ ] Stable·Advanced Create 경로가 충돌하지 않는다. +- [ ] Stable Starter가 Advanced dependency를 가져오지 않는다. +- [ ] Virtual Thread·blocking bridge가 admission과 pool limit을 유지한다. +- [ ] Merge Patch·JSON Patch가 field/pointer authorization과 If-Match를 통과한다. +- [ ] Streaming normal completion, terminal error, abrupt EOF를 구분한다. +- [ ] Slow consumer buffer와 connection 수가 bounded다. +- [ ] Last-Event-ID replay가 Messaging cursor와 연결되고 Web 자체 durable store가 없다. +- [ ] OpenAPI 3.2와 draft RateLimit headers가 Stable artifact를 변경하지 않는다. +- [ ] Feature flag off rollback test가 존재한다. +- [ ] 10k target streaming soak와 graceful drain evidence가 있다. +- [ ] 미확정 표식과 빈 구현 지시가 없다. + +## 4. 실행 인계 + +Advanced 구현은 `superpowers:subagent-driven-development`로 Task별 독립 review를 수행한다. Task 13 이후 Streaming checkpoint, Task 18 이후 compatibility checkpoint, Task 19에서 최종 promotion review를 진행한다. diff --git a/docs/web-superpowers-package/docs/superpowers/plans/2026-08-13-web-inbound-http-api-execution-platform-implementation-plan.md b/docs/web-superpowers-package/docs/superpowers/plans/2026-08-13-web-inbound-http-api-execution-platform-implementation-plan.md new file mode 100644 index 00000000..9e19b680 --- /dev/null +++ b/docs/web-superpowers-package/docs/superpowers/plans/2026-08-13-web-inbound-http-api-execution-platform-implementation-plan.md @@ -0,0 +1,4762 @@ +# 인바운드 HTTP API 실행 플랫폼 `web` 구현 계획서 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** HTTP 요청의 신뢰 경계 진입부터 Application Commit, HTTP 응답, Idempotency·Reconciliation, 계약·운영 검증까지 통제하는 Spring Boot 기반 인바운드 HTTP API 실행 플랫폼을 구현한다. + +**Architecture:** W1 Standard HTTP API를 공통 의미론 Core로 두고 Spring MVC·WebFlux를 상호 배타적인 Transport Adapter로 구현한다. 상태 변경의 안전성은 HTTP 응답 성공이 아니라 Application Commit Evidence와 동일 트랜잭션 Idempotency Record로 확보하고, Redis는 동시 요청 gate와 replay cache로만 사용한다. Stable 구현은 실제 Tomcat·Jetty·Reactor Netty·Nginx topology와 response-loss fault test를 통과해야 한다. + +**Tech Stack:** Java 21, Spring Boot 4.1 BOM, Spring Framework 7.0.x, Spring MVC, Spring WebFlux, Tomcat 11, Jetty 12.1, Reactor Netty, Jackson, Jakarta Validation, Spring Security, Micrometer, OpenAPI 3.1.2, PostgreSQL, Redis, Flyway, Testcontainers, Nginx, Gradle Kotlin DSL. + +## Global Constraints + +- Java 21과 Spring Boot 4.1 BOM이 모든 Spring·Jackson·Tomcat·Jetty·Reactor Netty 버전의 Source of Truth다. +- `web-core-api`는 Servlet, Spring MVC, Spring WebFlux, Reactor 타입에 의존하지 않는다. +- MVC와 WebFlux Starter는 상호 배타적이며 둘이 함께 존재하면 startup failure다. +- Controller는 Application Use Case Adapter이며 Repository, EntityManager, MongoTemplate, outbound client, broker client를 직접 호출하지 않는다. +- Controller 또는 HTTP adapter에 업무 `@Transactional` 경계를 두지 않는다. +- Request·Response는 전용 DTO를 사용하고 JPA Entity·MongoDB Document·Provider SDK 타입을 wire model로 노출하지 않는다. +- 성공 응답은 Resource·Collection·Operation DTO를 직접 반환하고 오류는 RFC 9457 `application/problem+json`을 사용한다. +- malformed·binding failure는 400, transport semantic validation은 422, business conflict는 409, HTTP precondition failure는 412다. +- Path major version `/api/v1`이 Stable 기본이고 OpenAPI 3.1.2가 승인 계약 artifact다. +- `ETag/If-Match`는 동시성 제어이고 Idempotency는 중복 실행 제어다. +- DB-local mutation은 business mutation과 authoritative idempotency commit evidence를 같은 DB transaction에 기록한다. +- Redis는 concurrent claim·replay cache를 제공할 수 있으나 DB commit evidence의 유일한 source가 아니다. +- `202 Accepted`는 durable operation row 또는 business DB transaction+outbox가 commit된 뒤에만 반환한다. +- WebFlux Stable profile에서 blocking JPA·blocking SDK의 event-loop 직접 호출을 금지한다. +- Forwarded headers는 trusted Nginx가 sanitize·재설정한 경우에만 신뢰한다. +- 모든 URI·header·body·JSON·response·execution budget은 hard upper bound를 가진다. +- Metric tag와 access log에는 전체 URL, query string, user/tenant/resource ID, idempotency key, token, cookie, body를 기록하지 않는다. +- Stable release는 Mock test뿐 아니라 실제 Tomcat·Jetty·Reactor Netty·Nginx·response-loss·abuse·performance·graceful-shutdown gate를 통과해야 한다. + +--- + +## 1. Stable 파일·모듈 구조 + +```text +modules/web/ +├── web-core-api +├── web-contract +├── web-validation +├── web-error +├── web-pagination +├── web-idempotency +├── web-idempotency-jpa +├── web-idempotency-redis +├── web-versioning +├── web-security-integration +├── web-observability +├── web-openapi +├── web-mvc +├── web-webflux +├── web-admin +├── web-operation-jpa +├── web-operation-messaging +├── web-spring-boot-starter-mvc +├── web-spring-boot-starter-webflux +├── web-testkit-core +├── web-testkit-mvc +├── web-testkit-webflux +└── web-testkit-contract + +examples/ +└── web-platform-sample +``` + +## 2. 단계별 실행 순서 + +```text +Task 1–14 +→ 모듈·Core·HTTP·JSON·Problem·Architecture Foundation + +Task 15–22 +→ MVC·Tomcat·Jetty·WebFlux·Reactor Netty·Stack Guard + +Task 23–30 +→ Security Context·Trusted Proxy·Versioning·Route·OpenAPI + +Task 31–36 +→ Collection Query·Signed Cursor·Conditional Request·Execution Evidence + +Task 37–43 +→ Idempotency Core·JPA Evidence·Redis Gate·Response-loss Recovery + +Task 44–48 +→ Durable Operation·Outbox·HTTP Resource·Cache + +Task 49–55 +→ Budget·Rate Limit·Admission·CORS/CSRF·Pipeline Order·Observability·Nginx + +Task 56–58 +→ Cross-stack Contract·Performance·Final Release Gate +``` + +각 Task는 두 단계 review를 거친다. + +1. **Specification review:** 설계서의 경계, 공개 타입, 상태 코드, 증거 의미가 그대로 구현됐는가. +2. **Quality review:** 테스트가 실제 failure mode를 재현하고 우회 가능한 raw 경로·무제한 queue·고 cardinality tag를 남기지 않는가. + +--- + +### Task 1: Stable Gradle 모듈과 집계 검증 구성 + +**Files:** +- Create: `settings.gradle.kts` +- Create: `modules/web/build.gradle.kts` +- Create: `modules/web/web-core-api/build.gradle.kts` +- Create: `modules/web/web-contract/build.gradle.kts` +- Create: `modules/web/web-validation/build.gradle.kts` +- Create: `modules/web/web-error/build.gradle.kts` +- Create: `modules/web/web-pagination/build.gradle.kts` +- Create: `modules/web/web-idempotency/build.gradle.kts` +- Create: `modules/web/web-versioning/build.gradle.kts` +- Create: `modules/web/web-security-integration/build.gradle.kts` +- Create: `modules/web/web-observability/build.gradle.kts` +- Create: `modules/web/web-openapi/build.gradle.kts` +- Create: `modules/web/web-mvc/build.gradle.kts` +- Create: `modules/web/web-webflux/build.gradle.kts` +- Create: `modules/web/web-admin/build.gradle.kts` +- Create: `modules/web/web-spring-boot-starter-mvc/build.gradle.kts` +- Create: `modules/web/web-spring-boot-starter-webflux/build.gradle.kts` +- Create: `modules/web/web-testkit-core/build.gradle.kts` +- Create: `modules/web/web-testkit-mvc/build.gradle.kts` +- Create: `modules/web/web-testkit-webflux/build.gradle.kts` +- Create: `modules/web/web-testkit-contract/build.gradle.kts` +- Modify: `build.gradle.kts` +- Test: `modules/web/src/test/kotlin/WebModuleGraphTest.kt` + +**Interfaces:** +- Consumes: Spring Boot 4.1 BOM, Java 21, Gradle Kotlin DSL +- Produces: Gradle project graph with stable modules and aggregate verification tasks + +**Implementation requirements:** +- Stable 모듈은 `web-advanced` 계열 모듈에 의존하지 않는다. +- `web-core-api`에는 Spring MVC, WebFlux, Servlet, Reactor 의존성을 추가하지 않는다. +- `web-spring-boot-starter-mvc`와 `web-spring-boot-starter-webflux`는 서로를 transitively 끌어오지 않는다. + +- [ ] **Step 1: Write the failing test** + +```java +import org.gradle.testkit.runner.GradleRunner +import kotlin.test.Test +import kotlin.test.assertTrue + +class WebModuleGraphTest { + @Test + fun `stable web modules are discoverable`() { + val result = GradleRunner.create() + .withProjectDir(java.io.File(".")) + .withArguments("projects") + .build() + assertTrue(result.output.contains(":modules:web:web-core-api")) + assertTrue(result.output.contains(":modules:web:web-spring-boot-starter-mvc")) + assertTrue(result.output.contains(":modules:web:web-spring-boot-starter-webflux")) + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the expected failure** + +Run: `./gradlew :modules:web:test --tests WebModuleGraphTest` + +Expected: FAIL because the web projects and aggregate task do not exist. + +- [ ] **Step 3: Implement the minimum production contract** + +```kotlin +plugins { + `java-library` +} + +java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(21)) + } +} + +tasks.register("webStableCheck") { + dependsOn( + subprojects.mapNotNull { it.tasks.findByName("check") } + ) +} +``` + +- [ ] **Step 4: Run the task test and its module contract suite** + +Run: `./gradlew webStableCheck` + +Expected: PASS with no skipped contract assertions and no new warnings. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'settings.gradle.kts' 'modules/web/build.gradle.kts' 'modules/web/web-core-api/build.gradle.kts' 'modules/web/web-contract/build.gradle.kts' 'modules/web/web-validation/build.gradle.kts' 'modules/web/web-error/build.gradle.kts' 'modules/web/web-pagination/build.gradle.kts' 'modules/web/web-idempotency/build.gradle.kts' 'modules/web/web-versioning/build.gradle.kts' 'modules/web/web-security-integration/build.gradle.kts' 'modules/web/web-observability/build.gradle.kts' 'modules/web/web-openapi/build.gradle.kts' 'modules/web/web-mvc/build.gradle.kts' 'modules/web/web-webflux/build.gradle.kts' 'modules/web/web-admin/build.gradle.kts' 'modules/web/web-spring-boot-starter-mvc/build.gradle.kts' 'modules/web/web-spring-boot-starter-webflux/build.gradle.kts' 'modules/web/web-testkit-core/build.gradle.kts' 'modules/web/web-testkit-mvc/build.gradle.kts' 'modules/web/web-testkit-webflux/build.gradle.kts' 'modules/web/web-testkit-contract/build.gradle.kts' 'build.gradle.kts' 'modules/web/src/test/kotlin/WebModuleGraphTest.kt' +git commit -m "build: add stable web platform module graph" +``` + +### Task 2: Core 식별자와 API Major Version 계약 + +**Files:** +- Create: `modules/web/web-core-api/src/main/java/io/backend/skeleton/web/core/WebOperationName.java` +- Create: `modules/web/web-core-api/src/main/java/io/backend/skeleton/web/core/WebRouteId.java` +- Create: `modules/web/web-core-api/src/main/java/io/backend/skeleton/web/core/WebRequestId.java` +- Create: `modules/web/web-core-api/src/main/java/io/backend/skeleton/web/core/WebTraceId.java` +- Create: `modules/web/web-core-api/src/main/java/io/backend/skeleton/web/core/ApiMajorVersion.java` +- Test: `modules/web/web-core-api/src/test/java/io/backend/skeleton/web/core/WebIdentifierTest.java` + +**Interfaces:** +- Consumes: Task 1 module graph +- Produces: `WebOperationName`, `WebRouteId`, `WebRequestId`, `WebTraceId`, `ApiMajorVersion` + +**Implementation requirements:** +- `WebOperationName`은 `[a-z][a-z0-9.-]{2,127}` 형식만 허용한다. +- `ApiMajorVersion`은 1 이상의 정수만 허용한다. +- 식별자 값은 null 또는 blank일 수 없다. + +- [ ] **Step 1: Write the failing test** + +```java +class WebIdentifierTest { + @org.junit.jupiter.api.Test + void rejectsInvalidOperationName() { + org.junit.jupiter.api.Assertions.assertThrows( + IllegalArgumentException.class, + () -> new WebOperationName("Create Order") + ); + } + + @org.junit.jupiter.api.Test + void acceptsVersionOne() { + org.junit.jupiter.api.Assertions.assertEquals(1, new ApiMajorVersion(1).value()); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the expected failure** + +Run: `./gradlew :modules:web:web-core-api:test --tests '*WebIdentifierTest'` + +Expected: FAIL because the identifier types do not exist. + +- [ ] **Step 3: Implement the minimum production contract** + +```java +public record WebOperationName(String value) { + public WebOperationName { + if (value == null || !value.matches("[a-z][a-z0-9.-]{2,127}")) { + throw new IllegalArgumentException("invalid web operation name"); + } + } +} + +public record ApiMajorVersion(int value) { + public ApiMajorVersion { + if (value < 1) { + throw new IllegalArgumentException("api major version must be positive"); + } + } +} +``` + +- [ ] **Step 4: Run the task test and its module contract suite** + +Run: `./gradlew :modules:web:web-core-api:test` + +Expected: PASS with no skipped contract assertions and no new warnings. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/web/web-core-api/src/main/java/io/backend/skeleton/web/core/WebOperationName.java' 'modules/web/web-core-api/src/main/java/io/backend/skeleton/web/core/WebRouteId.java' 'modules/web/web-core-api/src/main/java/io/backend/skeleton/web/core/WebRequestId.java' 'modules/web/web-core-api/src/main/java/io/backend/skeleton/web/core/WebTraceId.java' 'modules/web/web-core-api/src/main/java/io/backend/skeleton/web/core/ApiMajorVersion.java' 'modules/web/web-core-api/src/test/java/io/backend/skeleton/web/core/WebIdentifierTest.java' +git commit -m "feat(web): add core web identifiers" +``` + +### Task 3: 불변 Web Request Context 계약 + +**Files:** +- Create: `modules/web/web-core-api/src/main/java/io/backend/skeleton/web/core/WebRequestContext.java` +- Create: `modules/web/web-core-api/src/main/java/io/backend/skeleton/web/core/ExternalRequestContext.java` +- Create: `modules/web/web-core-api/src/main/java/io/backend/skeleton/web/core/ActorContext.java` +- Create: `modules/web/web-core-api/src/main/java/io/backend/skeleton/web/core/TenantContext.java` +- Test: `modules/web/web-core-api/src/test/java/io/backend/skeleton/web/core/WebRequestContextTest.java` + +**Interfaces:** +- Consumes: Task 2 identifiers +- Produces: `WebRequestContext` carrying actor, tenant, locale, deadline, and normalized external request + +**Implementation requirements:** +- Actor와 Tenant는 raw request parameter가 아니라 검증된 security context에서만 생성된다. +- `receivedAt`보다 이른 deadline은 거부한다. +- `ExternalRequestContext`는 normalized scheme, host, port, prefix만 보존한다. + +- [ ] **Step 1: Write the failing test** + +```java +class WebRequestContextTest { + @org.junit.jupiter.api.Test + void rejectsDeadlineBeforeReceiveTime() { + var now = java.time.Instant.parse("2026-08-13T00:00:00Z"); + org.junit.jupiter.api.Assertions.assertThrows( + IllegalArgumentException.class, + () -> WebRequestContext.createForTest(now, now.minusSeconds(1)) + ); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the expected failure** + +Run: `./gradlew :modules:web:web-core-api:test --tests '*WebRequestContextTest'` + +Expected: FAIL because WebRequestContext is missing. + +- [ ] **Step 3: Implement the minimum production contract** + +```java +public record WebRequestContext( + WebRequestId requestId, + WebTraceId traceId, + WebOperationName operationName, + ApiMajorVersion apiVersion, + ActorContext actor, + TenantContext tenant, + java.util.Locale locale, + java.time.Instant receivedAt, + java.time.Instant deadline, + ExternalRequestContext externalRequest) { + + public WebRequestContext { + java.util.Objects.requireNonNull(requestId); + java.util.Objects.requireNonNull(actor); + java.util.Objects.requireNonNull(tenant); + if (deadline.isBefore(receivedAt)) { + throw new IllegalArgumentException("deadline precedes receive time"); + } + } +} +``` + +- [ ] **Step 4: Run the task test and its module contract suite** + +Run: `./gradlew :modules:web:web-core-api:test` + +Expected: PASS with no skipped contract assertions and no new warnings. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/web/web-core-api/src/main/java/io/backend/skeleton/web/core/WebRequestContext.java' 'modules/web/web-core-api/src/main/java/io/backend/skeleton/web/core/ExternalRequestContext.java' 'modules/web/web-core-api/src/main/java/io/backend/skeleton/web/core/ActorContext.java' 'modules/web/web-core-api/src/main/java/io/backend/skeleton/web/core/TenantContext.java' 'modules/web/web-core-api/src/test/java/io/backend/skeleton/web/core/WebRequestContextTest.java' +git commit -m "feat(web): add immutable request context" +``` + +### Task 4: 세 축 HTTP 실행 증거 모델 + +**Files:** +- Create: `modules/web/web-core-api/src/main/java/io/backend/skeleton/web/evidence/WebRequestPhase.java` +- Create: `modules/web/web-core-api/src/main/java/io/backend/skeleton/web/evidence/WebApplicationEvidence.java` +- Create: `modules/web/web-core-api/src/main/java/io/backend/skeleton/web/evidence/WebResponseEvidence.java` +- Create: `modules/web/web-core-api/src/main/java/io/backend/skeleton/web/evidence/WebExecutionEvidence.java` +- Create: `modules/web/web-core-api/src/main/java/io/backend/skeleton/web/evidence/WebExecutionEvidenceTracker.java` +- Test: `modules/web/web-core-api/src/test/java/io/backend/skeleton/web/evidence/WebExecutionEvidenceTrackerTest.java` + +**Interfaces:** +- Consumes: Task 3 request context +- Produces: monotonic request, application, and response evidence transitions + +**Implementation requirements:** +- Request phase, application evidence, response evidence를 하나의 선형 상태로 합치지 않는다. +- Evidence transition은 뒤로 내려갈 수 없다. +- `RESPONSE_WRITE_COMPLETED_LOCALLY`는 client 관측을 의미하지 않는다. + +- [ ] **Step 1: Write the failing test** + +```java +class WebExecutionEvidenceTrackerTest { + @org.junit.jupiter.api.Test + void responseEvidenceNeverImpliesClientObservation() { + var tracker = WebExecutionEvidenceTracker.received(); + tracker.markApplicationCommitted(); + tracker.markLocalResponseWriteCompleted(); + var evidence = tracker.snapshot(); + + org.junit.jupiter.api.Assertions.assertEquals( + WebResponseEvidence.RESPONSE_WRITE_COMPLETED_LOCALLY, + evidence.responseEvidence() + ); + org.junit.jupiter.api.Assertions.assertNotEquals( + WebResponseEvidence.CLIENT_OBSERVATION_UNKNOWN, + evidence.responseEvidence() + ); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the expected failure** + +Run: `./gradlew :modules:web:web-core-api:test --tests '*WebExecutionEvidenceTrackerTest'` + +Expected: FAIL because the evidence model is not implemented. + +- [ ] **Step 3: Implement the minimum production contract** + +```java +public record WebExecutionEvidence( + WebRequestPhase requestPhase, + WebApplicationEvidence applicationEvidence, + WebResponseEvidence responseEvidence) { +} + +public final class WebExecutionEvidenceTracker { + private final java.util.concurrent.atomic.AtomicReference current; + + private WebExecutionEvidenceTracker(WebExecutionEvidence initial) { + this.current = new java.util.concurrent.atomic.AtomicReference<>(initial); + } + + public static WebExecutionEvidenceTracker received() { + return new WebExecutionEvidenceTracker(new WebExecutionEvidence( + WebRequestPhase.HTTP_RECEIVED, + WebApplicationEvidence.NOT_STARTED, + WebResponseEvidence.NOT_COMMITTED + )); + } + + public WebExecutionEvidence snapshot() { + return current.get(); + } +} +``` + +- [ ] **Step 4: Run the task test and its module contract suite** + +Run: `./gradlew :modules:web:web-core-api:test` + +Expected: PASS with no skipped contract assertions and no new warnings. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/web/web-core-api/src/main/java/io/backend/skeleton/web/evidence/WebRequestPhase.java' 'modules/web/web-core-api/src/main/java/io/backend/skeleton/web/evidence/WebApplicationEvidence.java' 'modules/web/web-core-api/src/main/java/io/backend/skeleton/web/evidence/WebResponseEvidence.java' 'modules/web/web-core-api/src/main/java/io/backend/skeleton/web/evidence/WebExecutionEvidence.java' 'modules/web/web-core-api/src/main/java/io/backend/skeleton/web/evidence/WebExecutionEvidenceTracker.java' 'modules/web/web-core-api/src/test/java/io/backend/skeleton/web/evidence/WebExecutionEvidenceTrackerTest.java' +git commit -m "feat(web): model inbound execution evidence" +``` + +### Task 5: Operation Profile과 등록 Catalog + +**Files:** +- Create: `modules/web/web-core-api/src/main/java/io/backend/skeleton/web/operation/WebOperationProfile.java` +- Create: `modules/web/web-core-api/src/main/java/io/backend/skeleton/web/operation/WebOperationCatalog.java` +- Create: `modules/web/web-core-api/src/main/java/io/backend/skeleton/web/operation/InMemoryWebOperationCatalog.java` +- Create: `modules/web/web-core-api/src/main/java/io/backend/skeleton/web/operation/MutationKind.java` +- Create: `modules/web/web-core-api/src/main/java/io/backend/skeleton/web/operation/HttpMethodSemantic.java` +- Test: `modules/web/web-core-api/src/test/java/io/backend/skeleton/web/operation/WebOperationCatalogTest.java` + +**Interfaces:** +- Consumes: Task 2 operation identifiers and Task 4 evidence terminology +- Produces: registered operation profiles with mutation, budget, authorization, idempotency, precondition, cache, admission, and response policies + +**Implementation requirements:** +- Production route는 등록되지 않은 operation name을 사용할 수 없다. +- READ_ONLY operation에 mutation-only idempotency requirement를 설정하면 startup validation이 실패한다. +- 동일 operation name의 중복 등록을 거부한다. + +- [ ] **Step 1: Write the failing test** + +```java +class WebOperationCatalogTest { + @org.junit.jupiter.api.Test + void rejectsDuplicateOperationName() { + var catalog = new InMemoryWebOperationCatalog(); + var profile = WebOperationProfile.readOnly("documents.get"); + catalog.register(profile); + + org.junit.jupiter.api.Assertions.assertThrows( + IllegalStateException.class, + () -> catalog.register(profile) + ); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the expected failure** + +Run: `./gradlew :modules:web:web-core-api:test --tests '*WebOperationCatalogTest'` + +Expected: FAIL because operation catalog classes are absent. + +- [ ] **Step 3: Implement the minimum production contract** + +```java +public interface WebOperationCatalog { + WebOperationProfile require(WebOperationName operationName); + void register(WebOperationProfile profile); +} + +public final class InMemoryWebOperationCatalog implements WebOperationCatalog { + private final java.util.Map profiles = + new java.util.concurrent.ConcurrentHashMap<>(); + + public void register(WebOperationProfile profile) { + if (profiles.putIfAbsent(profile.operationName(), profile) != null) { + throw new IllegalStateException("duplicate web operation"); + } + } + + public WebOperationProfile require(WebOperationName name) { + var profile = profiles.get(name); + if (profile == null) throw new IllegalArgumentException("unknown web operation"); + return profile; + } +} +``` + +- [ ] **Step 4: Run the task test and its module contract suite** + +Run: `./gradlew :modules:web:web-core-api:test` + +Expected: PASS with no skipped contract assertions and no new warnings. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/web/web-core-api/src/main/java/io/backend/skeleton/web/operation/WebOperationProfile.java' 'modules/web/web-core-api/src/main/java/io/backend/skeleton/web/operation/WebOperationCatalog.java' 'modules/web/web-core-api/src/main/java/io/backend/skeleton/web/operation/InMemoryWebOperationCatalog.java' 'modules/web/web-core-api/src/main/java/io/backend/skeleton/web/operation/MutationKind.java' 'modules/web/web-core-api/src/main/java/io/backend/skeleton/web/operation/HttpMethodSemantic.java' 'modules/web/web-core-api/src/test/java/io/backend/skeleton/web/operation/WebOperationCatalogTest.java' +git commit -m "feat(web): add operation policy catalog" +``` + +### Task 6: Request·Response Budget Profile + +**Files:** +- Create: `modules/web/web-core-api/src/main/java/io/backend/skeleton/web/budget/WebRequestBudget.java` +- Create: `modules/web/web-core-api/src/main/java/io/backend/skeleton/web/budget/WebBudgetProfileName.java` +- Create: `modules/web/web-core-api/src/main/java/io/backend/skeleton/web/budget/WebBudgetCatalog.java` +- Create: `modules/web/web-core-api/src/main/java/io/backend/skeleton/web/budget/WebBudgetViolation.java` +- Test: `modules/web/web-core-api/src/test/java/io/backend/skeleton/web/budget/WebRequestBudgetTest.java` + +**Interfaces:** +- Consumes: Task 5 operation profiles +- Produces: validated hard limits for URI, headers, query parameters, body, JSON depth, arrays, multipart, execution, and response + +**Implementation requirements:** +- 모든 제한은 양수여야 하며 조직 hard maximum을 초과할 수 없다. +- Route override는 global hard limit보다 작거나 같아야 한다. +- 실행 시간 제한이 없는 profile은 Production에서 거부한다. + +- [ ] **Step 1: Write the failing test** + +```java +class WebRequestBudgetTest { + @org.junit.jupiter.api.Test + void rejectsUnlimitedBody() { + org.junit.jupiter.api.Assertions.assertThrows( + IllegalArgumentException.class, + () -> new WebRequestBudget( + 8192, 16384, 100, Long.MAX_VALUE, + 64, 1000, 20, java.time.Duration.ofSeconds(30), 4_194_304 + ) + ); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the expected failure** + +Run: `./gradlew :modules:web:web-core-api:test --tests '*WebRequestBudgetTest'` + +Expected: FAIL because WebRequestBudget is missing. + +- [ ] **Step 3: Implement the minimum production contract** + +```java +public record WebRequestBudget( + int maxUriBytes, + int maxHeaderBytes, + int maxQueryParameters, + long maxBodyBytes, + int maxJsonDepth, + int maxArrayElements, + int maxMultipartParts, + java.time.Duration maxExecutionTime, + long maxResponseBytes) { + + private static final long ABSOLUTE_BODY_MAX = 8L * 1024 * 1024; + + public WebRequestBudget { + if (maxUriBytes <= 0 || maxHeaderBytes <= 0 || maxQueryParameters <= 0) { + throw new IllegalArgumentException("request limits must be positive"); + } + if (maxBodyBytes <= 0 || maxBodyBytes > ABSOLUTE_BODY_MAX) { + throw new IllegalArgumentException("body limit exceeds platform maximum"); + } + if (maxExecutionTime == null || maxExecutionTime.isZero() || maxExecutionTime.isNegative()) { + throw new IllegalArgumentException("execution time limit is required"); + } + } +} +``` + +- [ ] **Step 4: Run the task test and its module contract suite** + +Run: `./gradlew :modules:web:web-core-api:test` + +Expected: PASS with no skipped contract assertions and no new warnings. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/web/web-core-api/src/main/java/io/backend/skeleton/web/budget/WebRequestBudget.java' 'modules/web/web-core-api/src/main/java/io/backend/skeleton/web/budget/WebBudgetProfileName.java' 'modules/web/web-core-api/src/main/java/io/backend/skeleton/web/budget/WebBudgetCatalog.java' 'modules/web/web-core-api/src/main/java/io/backend/skeleton/web/budget/WebBudgetViolation.java' 'modules/web/web-core-api/src/test/java/io/backend/skeleton/web/budget/WebRequestBudgetTest.java' +git commit -m "feat(web): add bounded request and response budgets" +``` + +### Task 7: HTTP JSON Wire Type Manifest + +**Files:** +- Create: `modules/web/web-contract/src/main/java/io/backend/skeleton/web/contract/WebWireTypeManifest.java` +- Create: `modules/web/web-contract/src/main/java/io/backend/skeleton/web/contract/WebWireType.java` +- Create: `modules/web/web-contract/src/main/java/io/backend/skeleton/web/contract/WebEnumValue.java` +- Create: `modules/web/web-contract/src/main/resources/META-INF/web/wire-type-manifest.yaml` +- Test: `modules/web/web-contract/src/test/java/io/backend/skeleton/web/contract/WebWireTypeManifestTest.java` + +**Interfaces:** +- Consumes: Task 1 contract module and Task 6 hard limits +- Produces: one manifest for Instant, OffsetDateTime, LocalDate, Duration, UUID, BigDecimal, long, enum, URI, and Locale representations + +**Implementation requirements:** +- `Instant`는 UTC RFC 3339 문자열로 고정한다. +- JS safe integer 범위를 넘을 수 있는 long profile은 string wire type을 사용한다. +- Java enum name을 자동 wire value로 간주하지 않고 명시된 값만 사용한다. + +- [ ] **Step 1: Write the failing test** + +```java +class WebWireTypeManifestTest { + @org.junit.jupiter.api.Test + void instantUsesUtcRfc3339() { + var manifest = WebWireTypeManifest.standard(); + org.junit.jupiter.api.Assertions.assertEquals( + "RFC3339_UTC", + manifest.require(WebWireType.INSTANT).wireFormat() + ); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the expected failure** + +Run: `./gradlew :modules:web:web-contract:test --tests '*WebWireTypeManifestTest'` + +Expected: FAIL because the wire type manifest is absent. + +- [ ] **Step 3: Implement the minimum production contract** + +```java +public enum WebWireType { + INSTANT, + OFFSET_DATE_TIME, + LOCAL_DATE, + DURATION, + UUID, + BIG_DECIMAL, + LONG, + ENUM, + URI, + LOCALE +} + +public record WireTypeRule(String wireFormat, boolean nullable) {} + +public final class WebWireTypeManifest { + private final java.util.Map rules; + + public WireTypeManifest(java.util.Map rules) { + this.rules = java.util.Map.copyOf(rules); + } + + public WireTypeRule require(WebWireType type) { + return java.util.Objects.requireNonNull(rules.get(type), "missing wire type rule"); + } +} +``` + +- [ ] **Step 4: Run the task test and its module contract suite** + +Run: `./gradlew :modules:web:web-contract:test` + +Expected: PASS with no skipped contract assertions and no new warnings. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/web/web-contract/src/main/java/io/backend/skeleton/web/contract/WebWireTypeManifest.java' 'modules/web/web-contract/src/main/java/io/backend/skeleton/web/contract/WebWireType.java' 'modules/web/web-contract/src/main/java/io/backend/skeleton/web/contract/WebEnumValue.java' 'modules/web/web-contract/src/main/resources/META-INF/web/wire-type-manifest.yaml' 'modules/web/web-contract/src/test/java/io/backend/skeleton/web/contract/WebWireTypeManifestTest.java' +git commit -m "feat(web): define HTTP wire type manifest" +``` + +### Task 8: Strict Jackson JSON Profile + +**Files:** +- Create: `modules/web/web-contract/src/main/java/io/backend/skeleton/web/json/WebJsonProfile.java` +- Create: `modules/web/web-contract/src/main/java/io/backend/skeleton/web/json/WebObjectMapperFactory.java` +- Create: `modules/web/web-contract/src/main/java/io/backend/skeleton/web/json/BoundedJsonFactory.java` +- Create: `modules/web/web-contract/src/main/java/io/backend/skeleton/web/json/WebJsonDecodingException.java` +- Test: `modules/web/web-contract/src/test/java/io/backend/skeleton/web/json/WebObjectMapperFactoryTest.java` + +**Interfaces:** +- Consumes: Task 6 budget and Task 7 wire type manifest +- Produces: strict ObjectMapper with duplicate-key, unknown-property, trailing-token, enum, scalar coercion, and depth limits + +**Implementation requirements:** +- Mutation request의 unknown property를 거부한다. +- Duplicate JSON key와 trailing token을 거부한다. +- Number↔String, empty-string→null의 암묵 변환을 비활성화한다. + +- [ ] **Step 1: Write the failing test** + +```java +class WebObjectMapperFactoryTest { + record CreateRequest(String title) {} + + @org.junit.jupiter.api.Test + void rejectsDuplicateJsonKeys() { + var mapper = WebObjectMapperFactory.standard(); + org.junit.jupiter.api.Assertions.assertThrows( + Exception.class, + () -> mapper.readValue( + "{\"title\":\"a\",\"title\":\"b\"}", + CreateRequest.class + ) + ); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the expected failure** + +Run: `./gradlew :modules:web:web-contract:test --tests '*WebObjectMapperFactoryTest'` + +Expected: FAIL because strict JSON configuration does not exist. + +- [ ] **Step 3: Implement the minimum production contract** + +```java +public record WebJsonProfile( + boolean rejectUnknownProperties, + boolean rejectDuplicateKeys, + boolean rejectTrailingTokens, + boolean caseSensitiveEnums, + boolean rejectScalarCoercion, + int maxDepth, + int maxArrayElements, + int maxStringBytes) { +} + +public final class WebObjectMapperFactory { + public static com.fasterxml.jackson.databind.ObjectMapper standard() { + var factory = com.fasterxml.jackson.core.JsonFactory.builder() + .enable(com.fasterxml.jackson.core.StreamReadFeature.STRICT_DUPLICATE_DETECTION) + .streamReadConstraints(com.fasterxml.jackson.core.StreamReadConstraints.builder() + .maxNestingDepth(64) + .maxStringLength(1_048_576) + .build()) + .build(); + return com.fasterxml.jackson.databind.json.JsonMapper.builder(factory) + .enable(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .enable(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_TRAILING_TOKENS) + .build(); + } +} +``` + +- [ ] **Step 4: Run the task test and its module contract suite** + +Run: `./gradlew :modules:web:web-contract:test` + +Expected: PASS with no skipped contract assertions and no new warnings. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/web/web-contract/src/main/java/io/backend/skeleton/web/json/WebJsonProfile.java' 'modules/web/web-contract/src/main/java/io/backend/skeleton/web/json/WebObjectMapperFactory.java' 'modules/web/web-contract/src/main/java/io/backend/skeleton/web/json/BoundedJsonFactory.java' 'modules/web/web-contract/src/main/java/io/backend/skeleton/web/json/WebJsonDecodingException.java' 'modules/web/web-contract/src/test/java/io/backend/skeleton/web/json/WebObjectMapperFactoryTest.java' +git commit -m "feat(web): enforce strict JSON request profile" +``` + +### Task 9: Canonical URI와 HTTP Method Policy + +**Files:** +- Create: `modules/web/web-contract/src/main/java/io/backend/skeleton/web/http/WebUriPolicy.java` +- Create: `modules/web/web-contract/src/main/java/io/backend/skeleton/web/http/WebMethodPolicy.java` +- Create: `modules/web/web-contract/src/main/java/io/backend/skeleton/web/http/CanonicalPath.java` +- Create: `modules/web/web-contract/src/main/java/io/backend/skeleton/web/http/HttpMethodSemantic.java` +- Test: `modules/web/web-contract/src/test/java/io/backend/skeleton/web/http/WebUriPolicyTest.java` + +**Interfaces:** +- Consumes: Task 5 operation profiles +- Produces: canonical path validation and allowlisted GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS semantics + +**Implementation requirements:** +- Path는 case-sensitive이며 canonical trailing-slash form 하나만 허용한다. +- Duplicate slash, encoded slash `%2F`, matrix parameter를 기본 거부한다. +- TRACE, CONNECT, custom method는 Standard profile에서 거부한다. + +- [ ] **Step 1: Write the failing test** + +```java +class WebUriPolicyTest { + @org.junit.jupiter.api.Test + void rejectsEncodedSlashAndDuplicateSlash() { + var policy = WebUriPolicy.standard(); + org.junit.jupiter.api.Assertions.assertThrows( + IllegalArgumentException.class, + () -> policy.canonicalize("/api/v1/documents%2Fsecret") + ); + org.junit.jupiter.api.Assertions.assertThrows( + IllegalArgumentException.class, + () -> policy.canonicalize("/api//v1/documents") + ); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the expected failure** + +Run: `./gradlew :modules:web:web-contract:test --tests '*WebUriPolicyTest'` + +Expected: FAIL because URI policy classes are missing. + +- [ ] **Step 3: Implement the minimum production contract** + +```java +public final class WebUriPolicy { + public CanonicalPath canonicalize(String rawPath) { + if (rawPath == null || !rawPath.startsWith("/")) { + throw new IllegalArgumentException("absolute application path required"); + } + var lower = rawPath.toLowerCase(java.util.Locale.ROOT); + if (lower.contains("%2f") || rawPath.contains("//") || rawPath.contains(";")) { + throw new IllegalArgumentException("non-canonical path"); + } + if (rawPath.length() > 1 && rawPath.endsWith("/")) { + throw new IllegalArgumentException("trailing slash not canonical"); + } + return new CanonicalPath(rawPath); + } +} +``` + +- [ ] **Step 4: Run the task test and its module contract suite** + +Run: `./gradlew :modules:web:web-contract:test` + +Expected: PASS with no skipped contract assertions and no new warnings. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/web/web-contract/src/main/java/io/backend/skeleton/web/http/WebUriPolicy.java' 'modules/web/web-contract/src/main/java/io/backend/skeleton/web/http/WebMethodPolicy.java' 'modules/web/web-contract/src/main/java/io/backend/skeleton/web/http/CanonicalPath.java' 'modules/web/web-contract/src/main/java/io/backend/skeleton/web/http/HttpMethodSemantic.java' 'modules/web/web-contract/src/test/java/io/backend/skeleton/web/http/WebUriPolicyTest.java' +git commit -m "feat(web): add canonical URI and method policy" +``` + +### Task 10: HTTP 성공 응답·Status·Header 계약 + +**Files:** +- Create: `modules/web/web-contract/src/main/java/io/backend/skeleton/web/http/WebSuccessResponse.java` +- Create: `modules/web/web-contract/src/main/java/io/backend/skeleton/web/http/WebHeaderName.java` +- Create: `modules/web/web-contract/src/main/java/io/backend/skeleton/web/http/WebResponseContract.java` +- Create: `modules/web/web-contract/src/main/java/io/backend/skeleton/web/http/WebStatusContract.java` +- Test: `modules/web/web-contract/src/test/java/io/backend/skeleton/web/http/WebResponseContractTest.java` + +**Interfaces:** +- Consumes: Task 9 URI and method semantics +- Produces: 201 Location, 202 operation Location, 204/304 no-body, HEAD parity, and direct resource response rules + +**Implementation requirements:** +- 201에는 primary resource `Location`이 필수다. +- 202에는 durable operation `Location`이 필수다. +- 204와 304에는 body를 쓸 수 없다. +- 전역 `ApiResponse` envelope를 만들지 않는다. + +- [ ] **Step 1: Write the failing test** + +```java +class WebResponseContractTest { + @org.junit.jupiter.api.Test + void noContentCannotCarryBody() { + var contract = new WebResponseContract(); + org.junit.jupiter.api.Assertions.assertThrows( + IllegalArgumentException.class, + () -> contract.validate(204, java.util.Map.of(), "body") + ); + } + + @org.junit.jupiter.api.Test + void createdRequiresLocation() { + var contract = new WebResponseContract(); + org.junit.jupiter.api.Assertions.assertThrows( + IllegalArgumentException.class, + () -> contract.validate(201, java.util.Map.of(), new Object()) + ); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the expected failure** + +Run: `./gradlew :modules:web:web-contract:test --tests '*WebResponseContractTest'` + +Expected: FAIL because WebResponseContract does not exist. + +- [ ] **Step 3: Implement the minimum production contract** + +```java +public final class WebResponseContract { + public void validate(int status, java.util.Map headers, Object body) { + if ((status == 204 || status == 304) && body != null) { + throw new IllegalArgumentException("status forbids response body"); + } + if ((status == 201 || status == 202) && !headers.containsKey("Location")) { + throw new IllegalArgumentException("Location header required"); + } + } +} +``` + +- [ ] **Step 4: Run the task test and its module contract suite** + +Run: `./gradlew :modules:web:web-contract:test` + +Expected: PASS with no skipped contract assertions and no new warnings. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/web/web-contract/src/main/java/io/backend/skeleton/web/http/WebSuccessResponse.java' 'modules/web/web-contract/src/main/java/io/backend/skeleton/web/http/WebHeaderName.java' 'modules/web/web-contract/src/main/java/io/backend/skeleton/web/http/WebResponseContract.java' 'modules/web/web-contract/src/main/java/io/backend/skeleton/web/http/WebStatusContract.java' 'modules/web/web-contract/src/test/java/io/backend/skeleton/web/http/WebResponseContractTest.java' +git commit -m "feat(web): define HTTP status and response contracts" +``` + +### Task 11: RFC 9457 Problem Catalog와 Wire Model + +**Files:** +- Create: `modules/web/web-error/src/main/java/io/backend/skeleton/web/error/ProblemCode.java` +- Create: `modules/web/web-error/src/main/java/io/backend/skeleton/web/error/WebProblem.java` +- Create: `modules/web/web-error/src/main/java/io/backend/skeleton/web/error/ValidationIssue.java` +- Create: `modules/web/web-error/src/main/java/io/backend/skeleton/web/error/ProblemCatalog.java` +- Create: `modules/web/web-error/src/main/resources/META-INF/web/problem-catalog.yaml` +- Test: `modules/web/web-error/src/test/java/io/backend/skeleton/web/error/ProblemCatalogTest.java` + +**Interfaces:** +- Consumes: Task 10 status contract and Task 3 trace identifiers +- Produces: stable ProblemCode-to-status catalog and RFC 9457-compatible wire model + +**Implementation requirements:** +- `ProblemDetail.status`와 HTTP response status가 항상 일치한다. +- Problem payload에는 stack trace, Java exception, SQL, Mongo query, internal host, token, cookie, provider raw body, PII를 포함하지 않는다. +- `DEPENDENCY_FAILURE`는 등록 profile에서만 502 또는 503 중 하나로 고정한다. + +- [ ] **Step 1: Write the failing test** + +```java +class ProblemCatalogTest { + @org.junit.jupiter.api.Test + void validationMapsTo422() { + var catalog = ProblemCatalog.standard(); + org.junit.jupiter.api.Assertions.assertEquals( + 422, + catalog.require(ProblemCode.VALIDATION_FAILED).status() + ); + } + + @org.junit.jupiter.api.Test + void preconditionMapsTo412() { + org.junit.jupiter.api.Assertions.assertEquals( + 412, + ProblemCatalog.standard() + .require(ProblemCode.PRECONDITION_FAILED) + .status() + ); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the expected failure** + +Run: `./gradlew :modules:web:web-error:test --tests '*ProblemCatalogTest'` + +Expected: FAIL because ProblemCatalog and ProblemCode are missing. + +- [ ] **Step 3: Implement the minimum production contract** + +```java +public enum ProblemCode { + MALFORMED_REQUEST, + BINDING_FAILED, + VALIDATION_FAILED, + AUTHENTICATION_REQUIRED, + ACCESS_DENIED, + RESOURCE_NOT_FOUND, + RESOURCE_CONFLICT, + PRECONDITION_FAILED, + IDEMPOTENCY_KEY_REQUIRED, + IDEMPOTENCY_KEY_REUSED, + IDEMPOTENCY_REQUEST_IN_PROGRESS, + RATE_LIMITED, + ADMISSION_REJECTED, + DEPENDENCY_FAILURE, + DEPENDENCY_TIMEOUT, + METHOD_NOT_ALLOWED, + NOT_ACCEPTABLE, + RESOURCE_GONE, + REQUEST_TOO_LARGE, + UNSUPPORTED_MEDIA_TYPE, + RESPONSE_TOO_LARGE, + INTERNAL_ERROR +} + +public record ProblemDefinition( + java.net.URI type, + String title, + int status) { +} +``` + +- [ ] **Step 4: Run the task test and its module contract suite** + +Run: `./gradlew :modules:web:web-error:test` + +Expected: PASS with no skipped contract assertions and no new warnings. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/web/web-error/src/main/java/io/backend/skeleton/web/error/ProblemCode.java' 'modules/web/web-error/src/main/java/io/backend/skeleton/web/error/WebProblem.java' 'modules/web/web-error/src/main/java/io/backend/skeleton/web/error/ValidationIssue.java' 'modules/web/web-error/src/main/java/io/backend/skeleton/web/error/ProblemCatalog.java' 'modules/web/web-error/src/main/resources/META-INF/web/problem-catalog.yaml' 'modules/web/web-error/src/test/java/io/backend/skeleton/web/error/ProblemCatalogTest.java' +git commit -m "feat(web): add RFC 9457 problem catalog" +``` + +### Task 12: Problem Factory와 실제 Status 일치 Guard + +**Files:** +- Create: `modules/web/web-error/src/main/java/io/backend/skeleton/web/error/WebProblemFactory.java` +- Create: `modules/web/web-error/src/main/java/io/backend/skeleton/web/error/WebProblemSanitizer.java` +- Create: `modules/web/web-error/src/main/java/io/backend/skeleton/web/error/ProblemStatusMismatchException.java` +- Create: `modules/web/web-error/src/main/java/io/backend/skeleton/web/error/SafeProblemDetailExtensions.java` +- Test: `modules/web/web-error/src/test/java/io/backend/skeleton/web/error/WebProblemFactoryTest.java` + +**Interfaces:** +- Consumes: Task 11 problem catalog and Task 2 trace IDs +- Produces: `WebProblemFactory.create(code, detail, instance, traceId, errors)` and sanitization guard + +**Implementation requirements:** +- Factory만 Problem payload를 생성하며 Controller가 임의 extension을 추가하지 않는다. +- Detail은 catalog별 최대 길이로 자르고 제어 문자를 제거한다. +- HTTP status와 body status가 다르면 serialization 전에 실패한다. + +- [ ] **Step 1: Write the failing test** + +```java +class WebProblemFactoryTest { + @org.junit.jupiter.api.Test + void stripsStackAndKeepsStableCode() { + var factory = WebProblemFactory.standard(); + var problem = factory.create( + ProblemCode.INTERNAL_ERROR, + "java.lang.IllegalStateException\n at internal.Service", + java.net.URI.create("/problems/p1"), + "trace-1", + java.util.List.of() + ); + + org.junit.jupiter.api.Assertions.assertEquals(500, problem.status()); + org.junit.jupiter.api.Assertions.assertFalse(problem.detail().contains("internal.Service")); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the expected failure** + +Run: `./gradlew :modules:web:web-error:test --tests '*WebProblemFactoryTest'` + +Expected: FAIL because the factory and sanitizer are absent. + +- [ ] **Step 3: Implement the minimum production contract** + +```java +public final class WebProblemSanitizer { + public String sanitize(String input) { + if (input == null || input.isBlank()) { + return "요청을 처리할 수 없습니다."; + } + var firstLine = input.replaceAll("[\\r\\n]+", " ").trim(); + if (firstLine.contains("Exception") || firstLine.contains(" at ")) { + return "요청을 처리할 수 없습니다."; + } + return firstLine.substring(0, Math.min(firstLine.length(), 512)); + } +} + +public final class WebProblemFactory { + private final ProblemCatalog catalog; + private final WebProblemSanitizer sanitizer; + + public WebProblem create( + ProblemCode code, + String detail, + java.net.URI instance, + String traceId, + java.util.List errors) { + var definition = catalog.require(code); + return new WebProblem( + definition.type(), + definition.title(), + definition.status(), + sanitizer.sanitize(detail), + instance, + code, + traceId, + java.util.List.copyOf(errors) + ); + } +} +``` + +- [ ] **Step 4: Run the task test and its module contract suite** + +Run: `./gradlew :modules:web:web-error:test` + +Expected: PASS with no skipped contract assertions and no new warnings. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/web/web-error/src/main/java/io/backend/skeleton/web/error/WebProblemFactory.java' 'modules/web/web-error/src/main/java/io/backend/skeleton/web/error/WebProblemSanitizer.java' 'modules/web/web-error/src/main/java/io/backend/skeleton/web/error/ProblemStatusMismatchException.java' 'modules/web/web-error/src/main/java/io/backend/skeleton/web/error/SafeProblemDetailExtensions.java' 'modules/web/web-error/src/test/java/io/backend/skeleton/web/error/WebProblemFactoryTest.java' +git commit -m "feat(web): centralize sanitized problem creation" +``` + +### Task 13: Parsing·Binding·Validation 오류의 400·422 분리 + +**Files:** +- Create: `modules/web/web-validation/src/main/java/io/backend/skeleton/web/validation/WebValidationExceptionMapper.java` +- Create: `modules/web/web-validation/src/main/java/io/backend/skeleton/web/validation/WebValidationIssueMapper.java` +- Create: `modules/web/web-validation/src/main/java/io/backend/skeleton/web/validation/TransportValidationException.java` +- Create: `modules/web/web-validation/src/main/java/io/backend/skeleton/web/validation/WebInputPointer.java` +- Test: `modules/web/web-validation/src/test/java/io/backend/skeleton/web/validation/WebValidationExceptionMapperTest.java` + +**Interfaces:** +- Consumes: Task 8 strict JSON and Task 11 problem catalog +- Produces: deterministic mapping from parse/bind errors to 400 and semantic transport validation to 422 + +**Implementation requirements:** +- Malformed JSON, scalar conversion, unknown enum, duplicate key는 400이다. +- 정상 parse된 DTO의 Bean Validation과 cross-field transport validation은 422다. +- Transport validator는 DB, HTTP client, messaging에 접근할 수 없다. + +- [ ] **Step 1: Write the failing test** + +```java +class WebValidationExceptionMapperTest { + @org.junit.jupiter.api.Test + void malformedJsonIs400AndBeanValidationIs422() { + var mapper = new WebValidationExceptionMapper(); + + org.junit.jupiter.api.Assertions.assertEquals( + ProblemCode.MALFORMED_REQUEST, + mapper.codeFor(new com.fasterxml.jackson.core.JsonParseException(null, "bad")) + ); + org.junit.jupiter.api.Assertions.assertEquals( + ProblemCode.VALIDATION_FAILED, + mapper.codeFor(new TransportValidationException("invalid title")) + ); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the expected failure** + +Run: `./gradlew :modules:web:web-validation:test --tests '*WebValidationExceptionMapperTest'` + +Expected: FAIL because validation exception mapping is not implemented. + +- [ ] **Step 3: Implement the minimum production contract** + +```java +public final class WebValidationExceptionMapper { + public ProblemCode codeFor(Throwable failure) { + if (failure instanceof com.fasterxml.jackson.core.JsonProcessingException) { + return ProblemCode.MALFORMED_REQUEST; + } + if (failure instanceof org.springframework.core.convert.ConversionFailedException) { + return ProblemCode.BINDING_FAILED; + } + if (failure instanceof TransportValidationException) { + return ProblemCode.VALIDATION_FAILED; + } + throw new IllegalArgumentException("unmapped validation failure", failure); + } +} +``` + +- [ ] **Step 4: Run the task test and its module contract suite** + +Run: `./gradlew :modules:web:web-validation:test` + +Expected: PASS with no skipped contract assertions and no new warnings. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/web/web-validation/src/main/java/io/backend/skeleton/web/validation/WebValidationExceptionMapper.java' 'modules/web/web-validation/src/main/java/io/backend/skeleton/web/validation/WebValidationIssueMapper.java' 'modules/web/web-validation/src/main/java/io/backend/skeleton/web/validation/TransportValidationException.java' 'modules/web/web-validation/src/main/java/io/backend/skeleton/web/validation/WebInputPointer.java' 'modules/web/web-validation/src/test/java/io/backend/skeleton/web/validation/WebValidationExceptionMapperTest.java' +git commit -m "feat(web): distinguish malformed and semantically invalid requests" +``` + +### Task 14: Controller·DTO·Persistence 경계 ArchUnit 규칙 + +**Files:** +- Create: `modules/web/web-testkit-contract/src/main/java/io/backend/skeleton/web/testkit/architecture/WebArchitectureRules.java` +- Create: `modules/web/web-testkit-contract/src/main/java/io/backend/skeleton/web/testkit/architecture/WebForbiddenTypeCatalog.java` +- Test: `modules/web/web-testkit-contract/src/test/java/io/backend/skeleton/web/testkit/architecture/WebArchitectureRulesTest.java` + +**Interfaces:** +- Consumes: Tasks 1–13 stable modules and controller boundary from the design +- Produces: reusable ArchUnit rules for controller transaction, repository access, entity/document wire types, raw servlet/reactive types, and ApiResponse prohibition + +**Implementation requirements:** +- `..web..controller..`은 Repository, EntityManager, MongoTemplate, WebClient, KafkaTemplate, MinIO client에 직접 의존할 수 없다. +- Controller class와 method에 `@Transactional`을 사용할 수 없다. +- Request·Response DTO가 JPA `@Entity` 또는 Mongo `@Document` 타입을 참조할 수 없다. +- Domain/application package는 `HttpServletRequest`, `ServerWebExchange`, `ProblemDetail`을 참조할 수 없다. + +- [ ] **Step 1: Write the failing test** + +```java +class WebArchitectureRulesTest { + @org.junit.jupiter.api.Test + void controllerCannotDependOnJpaRepository() { + var rule = WebArchitectureRules.controllersAreUseCaseAdapters(); + rule.check( + new com.tngtech.archunit.core.importer.ClassFileImporter() + .importPackages("io.backend.skeleton.web.fixtures.badcontroller") + ); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the expected failure** + +Run: `./gradlew :modules:web:web-testkit-contract:test --tests '*WebArchitectureRulesTest'` + +Expected: FAIL because no architecture rules exist and the fixture violates the intended boundary. + +- [ ] **Step 3: Implement the minimum production contract** + +```java +public final class WebArchitectureRules { + public static com.tngtech.archunit.lang.ArchRule controllersAreUseCaseAdapters() { + return com.tngtech.archunit.lang.syntax.ArchRuleDefinition + .classes().that().resideInAPackage("..controller..") + .should().onlyDependOnClassesThat( + type -> !type.getPackageName().startsWith("org.springframework.data") + && !type.getPackageName().startsWith("jakarta.persistence") + ); + } + + public static com.tngtech.archunit.lang.ArchRule controllersAreNotTransactional() { + return com.tngtech.archunit.lang.syntax.ArchRuleDefinition + .noClasses().that().resideInAPackage("..controller..") + .should().beAnnotatedWith(org.springframework.transaction.annotation.Transactional.class); + } +} +``` + +- [ ] **Step 4: Run the task test and its module contract suite** + +Run: `./gradlew :modules:web:web-testkit-contract:test` + +Expected: PASS with no skipped contract assertions and no new warnings. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/web/web-testkit-contract/src/main/java/io/backend/skeleton/web/testkit/architecture/WebArchitectureRules.java' 'modules/web/web-testkit-contract/src/main/java/io/backend/skeleton/web/testkit/architecture/WebForbiddenTypeCatalog.java' 'modules/web/web-testkit-contract/src/test/java/io/backend/skeleton/web/testkit/architecture/WebArchitectureRulesTest.java' +git commit -m "test(web): enforce controller and wire-model boundaries" +``` + +### Task 15: MVC Starter 기본 자동 구성 + +**Files:** +- Create: `modules/web/web-spring-boot-starter-mvc/src/main/java/io/backend/skeleton/web/mvc/autoconfigure/WebMvcPlatformAutoConfiguration.java` +- Create: `modules/web/web-spring-boot-starter-mvc/src/main/java/io/backend/skeleton/web/mvc/autoconfigure/WebMvcPlatformProperties.java` +- Create: `modules/web/web-spring-boot-starter-mvc/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` +- Create: `modules/web/web-spring-boot-starter-mvc/src/main/resources/META-INF/additional-spring-configuration-metadata.json` +- Test: `modules/web/web-spring-boot-starter-mvc/src/test/java/io/backend/skeleton/web/mvc/autoconfigure/WebMvcPlatformAutoConfigurationTest.java` + +**Interfaces:** +- Consumes: Tasks 5–13 core policies, contract, validation, and error modules +- Produces: MVC auto-configuration wiring strict JSON, Problem Details, operation catalog, budgets, request context, and status guards + +**Implementation requirements:** +- Spring Boot BOM이 Spring MVC·Jackson·Tomcat 버전을 소유한다. +- Starter는 `spring-boot-starter-webflux`를 끌어오지 않는다. +- `spring.mvc.problemdetails.enabled` 여부와 무관하게 플랫폼 Problem factory가 일관된 wire contract를 소유한다. + +- [ ] **Step 1: Write the failing test** + +```java +class WebMvcPlatformAutoConfigurationTest { + private final org.springframework.boot.test.context.runner.WebApplicationContextRunner runner = + new org.springframework.boot.test.context.runner.WebApplicationContextRunner() + .withConfiguration(org.springframework.boot.autoconfigure.AutoConfigurations.of( + WebMvcPlatformAutoConfiguration.class + )); + + @org.junit.jupiter.api.Test + void registersStrictObjectMapperAndProblemFactory() { + runner.run(context -> { + org.assertj.core.api.Assertions.assertThat(context) + .hasSingleBean(WebProblemFactory.class) + .hasSingleBean(WebOperationCatalog.class); + }); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the expected failure** + +Run: `./gradlew :modules:web:web-spring-boot-starter-mvc:test --tests '*WebMvcPlatformAutoConfigurationTest'` + +Expected: FAIL because MVC platform auto-configuration does not exist. + +- [ ] **Step 3: Implement the minimum production contract** + +```java +@org.springframework.boot.autoconfigure.AutoConfiguration +@org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication( + type = org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication.Type.SERVLET +) +@org.springframework.context.annotation.Import({ + WebMvcErrorConfiguration.class, + WebMvcContextConfiguration.class, + WebMvcContractConfiguration.class +}) +public class WebMvcPlatformAutoConfiguration { +} +``` + +- [ ] **Step 4: Run the task test and its module contract suite** + +Run: `./gradlew :modules:web:web-spring-boot-starter-mvc:test` + +Expected: PASS with no skipped contract assertions and no new warnings. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/web/web-spring-boot-starter-mvc/src/main/java/io/backend/skeleton/web/mvc/autoconfigure/WebMvcPlatformAutoConfiguration.java' 'modules/web/web-spring-boot-starter-mvc/src/main/java/io/backend/skeleton/web/mvc/autoconfigure/WebMvcPlatformProperties.java' 'modules/web/web-spring-boot-starter-mvc/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports' 'modules/web/web-spring-boot-starter-mvc/src/main/resources/META-INF/additional-spring-configuration-metadata.json' 'modules/web/web-spring-boot-starter-mvc/src/test/java/io/backend/skeleton/web/mvc/autoconfigure/WebMvcPlatformAutoConfigurationTest.java' +git commit -m "feat(web): add MVC platform starter" +``` + +### Task 16: MVC Request Context·Trace·Evidence Filter Chain + +**Files:** +- Create: `modules/web/web-mvc/src/main/java/io/backend/skeleton/web/mvc/filter/WebMvcRequestIdFilter.java` +- Create: `modules/web/web-mvc/src/main/java/io/backend/skeleton/web/mvc/filter/WebMvcEvidenceFilter.java` +- Create: `modules/web/web-mvc/src/main/java/io/backend/skeleton/web/mvc/context/WebMvcRequestContextHolder.java` +- Create: `modules/web/web-mvc/src/main/java/io/backend/skeleton/web/mvc/context/WebMvcRequestContextArgumentResolver.java` +- Test: `modules/web/web-mvc/src/test/java/io/backend/skeleton/web/mvc/filter/WebMvcEvidenceFilterTest.java` + +**Interfaces:** +- Consumes: Task 3 request context, Task 4 evidence tracker, Task 15 MVC starter +- Produces: one request context and evidence tracker per logical request across REQUEST and ASYNC dispatch + +**Implementation requirements:** +- Request ID·trace ID는 외부 raw 값이 아니라 검증·정규화 후 사용한다. +- Async redispatch에서도 logical request evidence가 중복 초기화되지 않는다. +- Filter가 request body를 읽지 않는다. + +- [ ] **Step 1: Write the failing test** + +```java +class WebMvcEvidenceFilterTest { + @org.junit.jupiter.api.Test + void asyncRedispatchKeepsOneEvidenceTracker() throws Exception { + var request = new org.springframework.mock.web.MockHttpServletRequest(); + var response = new org.springframework.mock.web.MockHttpServletResponse(); + var filter = new WebMvcEvidenceFilter(); + + filter.doFilter(request, response, (req, res) -> { + var first = req.getAttribute(WebMvcEvidenceFilter.EVIDENCE_ATTRIBUTE); + req.setAttribute("first", first); + }); + + org.junit.jupiter.api.Assertions.assertSame( + request.getAttribute("first"), + request.getAttribute(WebMvcEvidenceFilter.EVIDENCE_ATTRIBUTE) + ); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the expected failure** + +Run: `./gradlew :modules:web:web-mvc:test --tests '*WebMvcEvidenceFilterTest'` + +Expected: FAIL because MVC evidence filter is missing. + +- [ ] **Step 3: Implement the minimum production contract** + +```java +public final class WebMvcEvidenceFilter + extends org.springframework.web.filter.OncePerRequestFilter { + + public static final String EVIDENCE_ATTRIBUTE = + WebMvcEvidenceFilter.class.getName() + ".evidence"; + + @Override + protected void doFilterInternal( + jakarta.servlet.http.HttpServletRequest request, + jakarta.servlet.http.HttpServletResponse response, + jakarta.servlet.FilterChain chain) + throws java.io.IOException, jakarta.servlet.ServletException { + + if (request.getAttribute(EVIDENCE_ATTRIBUTE) == null) { + request.setAttribute(EVIDENCE_ATTRIBUTE, WebExecutionEvidenceTracker.received()); + } + chain.doFilter(request, response); + } +} +``` + +- [ ] **Step 4: Run the task test and its module contract suite** + +Run: `./gradlew :modules:web:web-mvc:test` + +Expected: PASS with no skipped contract assertions and no new warnings. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/web/web-mvc/src/main/java/io/backend/skeleton/web/mvc/filter/WebMvcRequestIdFilter.java' 'modules/web/web-mvc/src/main/java/io/backend/skeleton/web/mvc/filter/WebMvcEvidenceFilter.java' 'modules/web/web-mvc/src/main/java/io/backend/skeleton/web/mvc/context/WebMvcRequestContextHolder.java' 'modules/web/web-mvc/src/main/java/io/backend/skeleton/web/mvc/context/WebMvcRequestContextArgumentResolver.java' 'modules/web/web-mvc/src/test/java/io/backend/skeleton/web/mvc/filter/WebMvcEvidenceFilterTest.java' +git commit -m "feat(web): propagate MVC request context and execution evidence" +``` + +### Task 17: 실제 Tomcat MVC HTTP 계약 Gate + +**Files:** +- Create: `modules/web/web-testkit-mvc/src/test/java/io/backend/skeleton/web/testkit/mvc/TomcatWebContractIT.java` +- Create: `modules/web/web-testkit-mvc/src/test/java/io/backend/skeleton/web/testkit/mvc/TomcatTestApplication.java` +- Create: `modules/web/web-testkit-mvc/src/test/java/io/backend/skeleton/web/testkit/mvc/ContractFixtureController.java` +- Test: `modules/web/web-testkit-mvc/src/test/resources/application-tomcat-contract.yaml` + +**Interfaces:** +- Consumes: Tasks 10–16 MVC contracts and starter +- Produces: real Tomcat tests for 201 Location, 204 body absence, HEAD parity, 400/405/406/415/422/500 Problem Details + +**Implementation requirements:** +- MockMvc 통과만으로 Stable을 선언하지 않는다. +- 실제 ephemeral port Tomcat을 기동하고 socket-level response를 검증한다. +- 204·304 body byte 수가 0인지 확인한다. + +- [ ] **Step 1: Write the failing test** + +```java +@org.springframework.boot.test.context.SpringBootTest( + classes = TomcatTestApplication.class, + webEnvironment = org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT +) +class TomcatWebContractIT { + @org.springframework.boot.test.web.server.LocalServerPort + int port; + + @org.junit.jupiter.api.Test + void createdHasLocationAndNoContentHasNoBody() throws Exception { + var client = java.net.http.HttpClient.newHttpClient(); + var created = client.send( + java.net.http.HttpRequest.newBuilder( + java.net.URI.create("http://localhost:" + port + "/api/v1/fixtures") + ).POST(java.net.http.HttpRequest.BodyPublishers.ofString("{\"name\":\"x\"}")) + .header("Content-Type", "application/json") + .build(), + java.net.http.HttpResponse.BodyHandlers.ofString() + ); + org.junit.jupiter.api.Assertions.assertEquals(201, created.statusCode()); + org.junit.jupiter.api.Assertions.assertTrue(created.headers().firstValue("Location").isPresent()); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the expected failure** + +Run: `./gradlew :modules:web:web-testkit-mvc:test --tests '*TomcatWebContractIT'` + +Expected: FAIL because the test application and MVC contract endpoint are not present. + +- [ ] **Step 3: Implement the minimum production contract** + +```java +@RestController +@RequestMapping("/api/v1/fixtures") +final class ContractFixtureController { + @PostMapping + ResponseEntity create(@Valid @RequestBody FixtureRequest request) { + return ResponseEntity + .created(URI.create("/api/v1/fixtures/f1")) + .body(new FixtureResponse("f1", request.name())); + } + + @DeleteMapping("/{id}") + ResponseEntity delete(@PathVariable String id) { + return ResponseEntity.noContent().build(); + } +} +``` + +- [ ] **Step 4: Run the task test and its module contract suite** + +Run: `./gradlew :modules:web:web-testkit-mvc:test` + +Expected: PASS with no skipped contract assertions and no new warnings. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/web/web-testkit-mvc/src/test/java/io/backend/skeleton/web/testkit/mvc/TomcatWebContractIT.java' 'modules/web/web-testkit-mvc/src/test/java/io/backend/skeleton/web/testkit/mvc/TomcatTestApplication.java' 'modules/web/web-testkit-mvc/src/test/java/io/backend/skeleton/web/testkit/mvc/ContractFixtureController.java' 'modules/web/web-testkit-mvc/src/test/resources/application-tomcat-contract.yaml' +git commit -m "test(web): certify real Tomcat HTTP semantics" +``` + +### Task 18: Jetty MVC 호환성 Gate + +**Files:** +- Create: `modules/web/web-testkit-mvc/src/test/java/io/backend/skeleton/web/testkit/mvc/JettyWebContractIT.java` +- Create: `modules/web/web-testkit-mvc/src/test/resources/application-jetty-contract.yaml` +- Modify: `.github/workflows/web-jetty-compat.yml` +- Test: `modules/web/web-testkit-mvc/src/test/java/io/backend/skeleton/web/testkit/mvc/JettyHeaderAndShutdownIT.java` + +**Interfaces:** +- Consumes: Task 17 shared MVC contract fixtures +- Produces: Jetty compatibility lane for status, headers, limits, async redispatch, and graceful shutdown + +**Implementation requirements:** +- Tomcat과 동일한 Stable HTTP contract suite를 실행한다. +- Jetty-specific container behavior를 공통 core 계약으로 역수입하지 않는다. +- Jetty lane 실패는 release compatibility gate를 차단한다. + +- [ ] **Step 1: Write the failing test** + +```java +class JettyHeaderAndShutdownIT { + @org.junit.jupiter.api.Test + void jettyProfileIsSelectedExplicitly() { + var server = System.getProperty("web.test.server"); + org.junit.jupiter.api.Assertions.assertEquals("jetty", server); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the expected failure** + +Run: `./gradlew :modules:web:web-testkit-mvc:jettyTest` + +Expected: FAIL because the Jetty compatibility test source set and dependency substitution are absent. + +- [ ] **Step 3: Implement the minimum production contract** + +```kotlin +val jettyTest by sourceSets.creating + +configurations[jettyTest.implementationConfigurationName].extendsFrom( + configurations.testImplementation.get() +) + +dependencies { + add(jettyTest.implementationConfigurationName, + "org.springframework.boot:spring-boot-starter-jetty") +} + +tasks.register("jettyContract") { + testClassesDirs = jettyTest.output.classesDirs + classpath = jettyTest.runtimeClasspath + systemProperty("web.test.server", "jetty") + useJUnitPlatform() +} +``` + +- [ ] **Step 4: Run the task test and its module contract suite** + +Run: `./gradlew :modules:web:web-testkit-mvc:jettyContract` + +Expected: PASS with no skipped contract assertions and no new warnings. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/web/web-testkit-mvc/src/test/java/io/backend/skeleton/web/testkit/mvc/JettyWebContractIT.java' 'modules/web/web-testkit-mvc/src/test/resources/application-jetty-contract.yaml' '.github/workflows/web-jetty-compat.yml' 'modules/web/web-testkit-mvc/src/test/java/io/backend/skeleton/web/testkit/mvc/JettyHeaderAndShutdownIT.java' +git commit -m "test(web): add Jetty MVC compatibility gate" +``` + +### Task 19: WebFlux Starter 기본 자동 구성 + +**Files:** +- Create: `modules/web/web-spring-boot-starter-webflux/src/main/java/io/backend/skeleton/web/webflux/autoconfigure/WebFluxPlatformAutoConfiguration.java` +- Create: `modules/web/web-spring-boot-starter-webflux/src/main/java/io/backend/skeleton/web/webflux/autoconfigure/WebFluxPlatformProperties.java` +- Create: `modules/web/web-spring-boot-starter-webflux/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` +- Create: `modules/web/web-spring-boot-starter-webflux/src/main/resources/META-INF/additional-spring-configuration-metadata.json` +- Test: `modules/web/web-spring-boot-starter-webflux/src/test/java/io/backend/skeleton/web/webflux/autoconfigure/WebFluxPlatformAutoConfigurationTest.java` + +**Interfaces:** +- Consumes: Tasks 5–13 shared policies without Servlet dependencies +- Produces: Reactive WebFlux auto-configuration wiring shared contracts, Reactor context, errors, budgets, and strict codecs + +**Implementation requirements:** +- Starter는 MVC·Servlet 의존성을 끌어오지 않는다. +- Reactor Netty가 Stable WebFlux server baseline이다. +- WebFlux request context는 Reactor Context에 저장하고 ThreadLocal을 원천으로 사용하지 않는다. + +- [ ] **Step 1: Write the failing test** + +```java +class WebFluxPlatformAutoConfigurationTest { + private final org.springframework.boot.test.context.runner.ReactiveWebApplicationContextRunner runner = + new org.springframework.boot.test.context.runner.ReactiveWebApplicationContextRunner() + .withConfiguration(org.springframework.boot.autoconfigure.AutoConfigurations.of( + WebFluxPlatformAutoConfiguration.class + )); + + @org.junit.jupiter.api.Test + void registersReactiveProblemHandlerAndContextFilter() { + runner.run(context -> { + org.assertj.core.api.Assertions.assertThat(context) + .hasSingleBean(WebFluxProblemExceptionHandler.class) + .hasSingleBean(WebFluxRequestContextFilter.class); + }); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the expected failure** + +Run: `./gradlew :modules:web:web-spring-boot-starter-webflux:test --tests '*WebFluxPlatformAutoConfigurationTest'` + +Expected: FAIL because WebFlux platform auto-configuration is absent. + +- [ ] **Step 3: Implement the minimum production contract** + +```java +@org.springframework.boot.autoconfigure.AutoConfiguration +@org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication( + type = org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication.Type.REACTIVE +) +@org.springframework.context.annotation.Import({ + WebFluxErrorConfiguration.class, + WebFluxContextConfiguration.class, + WebFluxContractConfiguration.class +}) +public class WebFluxPlatformAutoConfiguration { +} +``` + +- [ ] **Step 4: Run the task test and its module contract suite** + +Run: `./gradlew :modules:web:web-spring-boot-starter-webflux:test` + +Expected: PASS with no skipped contract assertions and no new warnings. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/web/web-spring-boot-starter-webflux/src/main/java/io/backend/skeleton/web/webflux/autoconfigure/WebFluxPlatformAutoConfiguration.java' 'modules/web/web-spring-boot-starter-webflux/src/main/java/io/backend/skeleton/web/webflux/autoconfigure/WebFluxPlatformProperties.java' 'modules/web/web-spring-boot-starter-webflux/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports' 'modules/web/web-spring-boot-starter-webflux/src/main/resources/META-INF/additional-spring-configuration-metadata.json' 'modules/web/web-spring-boot-starter-webflux/src/test/java/io/backend/skeleton/web/webflux/autoconfigure/WebFluxPlatformAutoConfigurationTest.java' +git commit -m "feat(web): add WebFlux platform starter" +``` + +### Task 20: Reactor Context 전파와 Event-loop Blocking Guard + +**Files:** +- Create: `modules/web/web-webflux/src/main/java/io/backend/skeleton/web/webflux/context/WebFluxRequestContextFilter.java` +- Create: `modules/web/web-webflux/src/main/java/io/backend/skeleton/web/webflux/context/WebFluxRequestContextAccessor.java` +- Create: `modules/web/web-webflux/src/main/java/io/backend/skeleton/web/webflux/guard/BlockingDependencyGuard.java` +- Create: `modules/web/web-webflux/src/main/java/io/backend/skeleton/web/webflux/guard/BlockingCallDetectedException.java` +- Test: `modules/web/web-webflux/src/test/java/io/backend/skeleton/web/webflux/guard/BlockingDependencyGuardTest.java` + +**Interfaces:** +- Consumes: Task 3 request context, Task 4 evidence tracker, Task 19 WebFlux starter +- Produces: request-scoped Reactor Context and fail-fast detection of registered blocking calls on event-loop threads + +**Implementation requirements:** +- WebFlux Stable profile에서 blocking JPA·blocking Mongo·blocking SDK를 event-loop에서 직접 호출할 수 없다. +- Context loss가 발생하면 anonymous/empty actor로 대체하지 않고 요청을 실패시킨다. +- Blocking bridge는 Advanced module에서만 제공한다. + +- [ ] **Step 1: Write the failing test** + +```java +class BlockingDependencyGuardTest { + @org.junit.jupiter.api.Test + void rejectsBlockingCallOnReactorHttpThread() { + var guard = new BlockingDependencyGuard(name -> name.startsWith("reactor-http-")); + var thread = new Thread( + () -> org.junit.jupiter.api.Assertions.assertThrows( + BlockingCallDetectedException.class, + () -> guard.check("jpa") + ), + "reactor-http-nio-1" + ); + thread.start(); + org.junit.jupiter.api.Assertions.assertDoesNotThrow(thread::join); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the expected failure** + +Run: `./gradlew :modules:web:web-webflux:test --tests '*BlockingDependencyGuardTest'` + +Expected: FAIL because no event-loop blocking guard exists. + +- [ ] **Step 3: Implement the minimum production contract** + +```java +public final class BlockingDependencyGuard { + private final java.util.function.Predicate eventLoopThread; + + public BlockingDependencyGuard( + java.util.function.Predicate eventLoopThread) { + this.eventLoopThread = eventLoopThread; + } + + public void check(String dependency) { + if (eventLoopThread.test(Thread.currentThread().getName())) { + throw new BlockingCallDetectedException( + "blocking dependency on event loop: " + dependency + ); + } + } +} +``` + +- [ ] **Step 4: Run the task test and its module contract suite** + +Run: `./gradlew :modules:web:web-webflux:test` + +Expected: PASS with no skipped contract assertions and no new warnings. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/web/web-webflux/src/main/java/io/backend/skeleton/web/webflux/context/WebFluxRequestContextFilter.java' 'modules/web/web-webflux/src/main/java/io/backend/skeleton/web/webflux/context/WebFluxRequestContextAccessor.java' 'modules/web/web-webflux/src/main/java/io/backend/skeleton/web/webflux/guard/BlockingDependencyGuard.java' 'modules/web/web-webflux/src/main/java/io/backend/skeleton/web/webflux/guard/BlockingCallDetectedException.java' 'modules/web/web-webflux/src/test/java/io/backend/skeleton/web/webflux/guard/BlockingDependencyGuardTest.java' +git commit -m "feat(web): enforce reactive context and event-loop safety" +``` + +### Task 21: 실제 Reactor Netty WebFlux 계약 Gate + +**Files:** +- Create: `modules/web/web-testkit-webflux/src/test/java/io/backend/skeleton/web/testkit/webflux/ReactorNettyWebContractIT.java` +- Create: `modules/web/web-testkit-webflux/src/test/java/io/backend/skeleton/web/testkit/webflux/WebFluxTestApplication.java` +- Create: `modules/web/web-testkit-webflux/src/test/java/io/backend/skeleton/web/testkit/webflux/ReactiveContractFixtureController.java` +- Create: `modules/web/web-testkit-webflux/src/test/resources/application-reactor-netty-contract.yaml` +- Test: `modules/web/web-testkit-webflux/src/test/java/io/backend/skeleton/web/testkit/webflux/ReactorNettyErrorAndLimitIT.java` + +**Interfaces:** +- Consumes: Tasks 19–20 WebFlux starter, context, error, and budget policies +- Produces: real Reactor Netty HTTP tests for media negotiation, Problem Details, limits, cancellation, and context propagation + +**Implementation requirements:** +- `WebTestClient.bindToController`만으로 Stable을 선언하지 않는다. +- 실제 Reactor Netty socket과 random port를 사용한다. +- event-loop thread 이름, cancellation, response-size guard를 검증한다. + +- [ ] **Step 1: Write the failing test** + +```java +@org.springframework.boot.test.context.SpringBootTest( + classes = WebFluxTestApplication.class, + webEnvironment = org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT +) +class ReactorNettyErrorAndLimitIT { + @org.springframework.beans.factory.annotation.Autowired + org.springframework.test.web.reactive.server.WebTestClient client; + + @org.junit.jupiter.api.Test + void semanticValidationUses422ProblemJson() { + client.post() + .uri("/api/v1/reactive-fixtures") + .contentType(org.springframework.http.MediaType.APPLICATION_JSON) + .bodyValue("{\"name\":\"\"}") + .exchange() + .expectStatus().isEqualTo(422) + .expectHeader().contentTypeCompatibleWith( + org.springframework.http.MediaType.APPLICATION_PROBLEM_JSON + ); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the expected failure** + +Run: `./gradlew :modules:web:web-testkit-webflux:test --tests '*ReactorNettyErrorAndLimitIT'` + +Expected: FAIL because the real WebFlux test application is not configured. + +- [ ] **Step 3: Implement the minimum production contract** + +```java +@RestController +@RequestMapping("/api/v1/reactive-fixtures") +final class ReactiveContractFixtureController { + @PostMapping + reactor.core.publisher.Mono> create( + @Valid @RequestBody reactor.core.publisher.Mono input) { + return input.map(request -> ResponseEntity + .created(java.net.URI.create("/api/v1/reactive-fixtures/f1")) + .body(new FixtureResponse("f1", request.name()))); + } +} +``` + +- [ ] **Step 4: Run the task test and its module contract suite** + +Run: `./gradlew :modules:web:web-testkit-webflux:test` + +Expected: PASS with no skipped contract assertions and no new warnings. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/web/web-testkit-webflux/src/test/java/io/backend/skeleton/web/testkit/webflux/ReactorNettyWebContractIT.java' 'modules/web/web-testkit-webflux/src/test/java/io/backend/skeleton/web/testkit/webflux/WebFluxTestApplication.java' 'modules/web/web-testkit-webflux/src/test/java/io/backend/skeleton/web/testkit/webflux/ReactiveContractFixtureController.java' 'modules/web/web-testkit-webflux/src/test/resources/application-reactor-netty-contract.yaml' 'modules/web/web-testkit-webflux/src/test/java/io/backend/skeleton/web/testkit/webflux/ReactorNettyErrorAndLimitIT.java' +git commit -m "test(web): certify real Reactor Netty HTTP contracts" +``` + +### Task 22: MVC·WebFlux Starter 상호 배타성 Startup Guard + +**Files:** +- Create: `modules/web/web-core-api/src/main/java/io/backend/skeleton/web/startup/WebStackKind.java` +- Create: `modules/web/web-core-api/src/main/java/io/backend/skeleton/web/startup/WebStackConflictDetector.java` +- Create: `modules/web/web-spring-boot-starter-mvc/src/main/java/io/backend/skeleton/web/mvc/autoconfigure/WebMvcStackGuard.java` +- Create: `modules/web/web-spring-boot-starter-webflux/src/main/java/io/backend/skeleton/web/webflux/autoconfigure/WebFluxStackGuard.java` +- Test: `modules/web/web-testkit-contract/src/test/java/io/backend/skeleton/web/testkit/startup/WebStackConflictDetectorTest.java` + +**Interfaces:** +- Consumes: Tasks 15 and 19 starters +- Produces: startup failure when both MVC and WebFlux platform starters are present + +**Implementation requirements:** +- Spring Boot가 우연히 MVC를 선택하도록 두지 않는다. +- 사용자는 `MVC` 또는 `WEBFLUX` stack을 명시적으로 선택해야 한다. +- 테스트 fixture에서만 conflict guard를 명시적으로 비활성화할 수 있다. + +- [ ] **Step 1: Write the failing test** + +```java +class WebStackConflictDetectorTest { + @org.junit.jupiter.api.Test + void rejectsBothStacks() { + var detector = new WebStackConflictDetector(); + org.junit.jupiter.api.Assertions.assertThrows( + IllegalStateException.class, + () -> detector.validate( + java.util.Set.of(WebStackKind.MVC, WebStackKind.WEBFLUX) + ) + ); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the expected failure** + +Run: `./gradlew :modules:web:web-testkit-contract:test --tests '*WebStackConflictDetectorTest'` + +Expected: FAIL because stack conflict detection is missing. + +- [ ] **Step 3: Implement the minimum production contract** + +```java +public enum WebStackKind { + MVC, + WEBFLUX +} + +public final class WebStackConflictDetector { + public void validate(java.util.Set discovered) { + if (discovered.contains(WebStackKind.MVC) + && discovered.contains(WebStackKind.WEBFLUX)) { + throw new IllegalStateException( + "MVC and WebFlux platform starters are mutually exclusive" + ); + } + } +} +``` + +- [ ] **Step 4: Run the task test and its module contract suite** + +Run: `./gradlew :modules:web:web-testkit-contract:test` + +Expected: PASS with no skipped contract assertions and no new warnings. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/web/web-core-api/src/main/java/io/backend/skeleton/web/startup/WebStackKind.java' 'modules/web/web-core-api/src/main/java/io/backend/skeleton/web/startup/WebStackConflictDetector.java' 'modules/web/web-spring-boot-starter-mvc/src/main/java/io/backend/skeleton/web/mvc/autoconfigure/WebMvcStackGuard.java' 'modules/web/web-spring-boot-starter-webflux/src/main/java/io/backend/skeleton/web/webflux/autoconfigure/WebFluxStackGuard.java' 'modules/web/web-testkit-contract/src/test/java/io/backend/skeleton/web/testkit/startup/WebStackConflictDetectorTest.java' +git commit -m "feat(web): fail fast on mixed MVC and WebFlux starters" +``` + +### Task 23: Actor·Tenant Security Context Bridge + +**Files:** +- Create: `modules/web/web-security-integration/src/main/java/io/backend/skeleton/web/security/WebActorContextResolver.java` +- Create: `modules/web/web-security-integration/src/main/java/io/backend/skeleton/web/security/WebTenantContextResolver.java` +- Create: `modules/web/web-security-integration/src/main/java/io/backend/skeleton/web/security/WebSecurityContextBridge.java` +- Create: `modules/web/web-security-integration/src/main/java/io/backend/skeleton/web/security/UntrustedTenantInputException.java` +- Test: `modules/web/web-security-integration/src/test/java/io/backend/skeleton/web/security/WebSecurityContextBridgeTest.java` + +**Interfaces:** +- Consumes: Task 3 request context and existing security module Actor/Tenant contracts +- Produces: validated actor and tenant resolution from authentication/session, never from raw request parameters + +**Implementation requirements:** +- `tenantId` query/header를 검증 없이 TenantContext로 승격하지 않는다. +- 인증은 route access만 제공하며 object/property authorization을 완료한 것으로 간주하지 않는다. +- Web layer는 token verification을 다시 구현하지 않는다. + +- [ ] **Step 1: Write the failing test** + +```java +class WebSecurityContextBridgeTest { + @org.junit.jupiter.api.Test + void ignoresRawTenantHeaderAndUsesAuthenticatedTenant() { + var bridge = WebSecurityContextBridge.fixture("actor-1", "tenant-authenticated"); + var resolved = bridge.resolve( + java.util.Map.of("X-Tenant-Id", "tenant-attacker") + ); + + org.junit.jupiter.api.Assertions.assertEquals( + "tenant-authenticated", + resolved.tenant().value() + ); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the expected failure** + +Run: `./gradlew :modules:web:web-security-integration:test --tests '*WebSecurityContextBridgeTest'` + +Expected: FAIL because no authenticated actor/tenant bridge exists. + +- [ ] **Step 3: Implement the minimum production contract** + +```java +public final class WebSecurityContextBridge { + private final WebActorContextResolver actorResolver; + private final WebTenantContextResolver tenantResolver; + + public SecurityIdentity resolve(AuthenticationView authentication) { + if (!authentication.authenticated()) { + throw new org.springframework.security.authentication + .AuthenticationCredentialsNotFoundException("authentication required"); + } + return new SecurityIdentity( + actorResolver.resolve(authentication), + tenantResolver.resolve(authentication) + ); + } +} +``` + +- [ ] **Step 4: Run the task test and its module contract suite** + +Run: `./gradlew :modules:web:web-security-integration:test` + +Expected: PASS with no skipped contract assertions and no new warnings. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/web/web-security-integration/src/main/java/io/backend/skeleton/web/security/WebActorContextResolver.java' 'modules/web/web-security-integration/src/main/java/io/backend/skeleton/web/security/WebTenantContextResolver.java' 'modules/web/web-security-integration/src/main/java/io/backend/skeleton/web/security/WebSecurityContextBridge.java' 'modules/web/web-security-integration/src/main/java/io/backend/skeleton/web/security/UntrustedTenantInputException.java' 'modules/web/web-security-integration/src/test/java/io/backend/skeleton/web/security/WebSecurityContextBridgeTest.java' +git commit -m "feat(web): bridge authenticated actor and tenant context" +``` + +### Task 24: Trusted Proxy와 Forwarded Header 정규화 + +**Files:** +- Create: `modules/web/web-security-integration/src/main/java/io/backend/skeleton/web/proxy/TrustedProxyPolicy.java` +- Create: `modules/web/web-security-integration/src/main/java/io/backend/skeleton/web/proxy/ForwardedHeaderSanitizer.java` +- Create: `modules/web/web-security-integration/src/main/java/io/backend/skeleton/web/proxy/NormalizedForwardedHeaders.java` +- Create: `modules/web/web-security-integration/src/main/java/io/backend/skeleton/web/proxy/UntrustedForwardedHeaderException.java` +- Test: `modules/web/web-security-integration/src/test/java/io/backend/skeleton/web/proxy/ForwardedHeaderSanitizerTest.java` + +**Interfaces:** +- Consumes: Task 3 ExternalRequestContext and trusted network configuration +- Produces: one normalized external scheme, host, port, prefix, and client address only from configured trusted proxies + +**Implementation requirements:** +- 외부 client가 보낸 `Forwarded`·`X-Forwarded-*`를 직접 신뢰하지 않는다. +- Trusted Nginx가 기존 forwarded headers를 제거하고 authoritative 값을 다시 설정한다는 topology를 전제로 한다. +- Direct-access profile에서는 forwarded processing을 비활성화한다. + +- [ ] **Step 1: Write the failing test** + +```java +class ForwardedHeaderSanitizerTest { + @org.junit.jupiter.api.Test + void rejectsForwardedHeadersFromUntrustedPeer() { + var policy = TrustedProxyPolicy.of("10.0.0.0/8"); + var sanitizer = new ForwardedHeaderSanitizer(policy); + + org.junit.jupiter.api.Assertions.assertThrows( + UntrustedForwardedHeaderException.class, + () -> sanitizer.normalize( + "203.0.113.10", + java.util.Map.of("X-Forwarded-Host", "evil.example") + ) + ); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the expected failure** + +Run: `./gradlew :modules:web:web-security-integration:test --tests '*ForwardedHeaderSanitizerTest'` + +Expected: FAIL because trusted proxy normalization is absent. + +- [ ] **Step 3: Implement the minimum production contract** + +```java +public final class ForwardedHeaderSanitizer { + private final TrustedProxyPolicy trustedProxyPolicy; + + public NormalizedForwardedHeaders normalize( + java.net.InetAddress peer, + java.util.Map headers) { + if (containsForwarded(headers) && !trustedProxyPolicy.isTrusted(peer)) { + throw new UntrustedForwardedHeaderException(peer.getHostAddress()); + } + return NormalizedForwardedHeaders.fromTrustedHeaders(headers); + } +} +``` + +- [ ] **Step 4: Run the task test and its module contract suite** + +Run: `./gradlew :modules:web:web-security-integration:test` + +Expected: PASS with no skipped contract assertions and no new warnings. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/web/web-security-integration/src/main/java/io/backend/skeleton/web/proxy/TrustedProxyPolicy.java' 'modules/web/web-security-integration/src/main/java/io/backend/skeleton/web/proxy/ForwardedHeaderSanitizer.java' 'modules/web/web-security-integration/src/main/java/io/backend/skeleton/web/proxy/NormalizedForwardedHeaders.java' 'modules/web/web-security-integration/src/main/java/io/backend/skeleton/web/proxy/UntrustedForwardedHeaderException.java' 'modules/web/web-security-integration/src/test/java/io/backend/skeleton/web/proxy/ForwardedHeaderSanitizerTest.java' +git commit -m "feat(web): enforce trusted forwarded-header boundary" +``` + +### Task 25: External URL·Location·Prefix Builder + +**Files:** +- Create: `modules/web/web-contract/src/main/java/io/backend/skeleton/web/http/ExternalUriBuilder.java` +- Create: `modules/web/web-contract/src/main/java/io/backend/skeleton/web/http/ExternalOrigin.java` +- Create: `modules/web/web-contract/src/main/java/io/backend/skeleton/web/http/ExternalPrefix.java` +- Create: `modules/web/web-contract/src/main/java/io/backend/skeleton/web/http/ExternalUriPolicy.java` +- Test: `modules/web/web-contract/src/test/java/io/backend/skeleton/web/http/ExternalUriBuilderTest.java` + +**Interfaces:** +- Consumes: Task 24 normalized forwarded headers and Task 9 URI policy +- Produces: safe absolute and relative Location/Link construction for `/api` and `/dev-api` without double prefix + +**Implementation requirements:** +- Raw `Host`, `X-Forwarded-Host`, request parameter를 absolute URL에 직접 사용하지 않는다. +- `X-Forwarded-Prefix`는 한 번만 적용한다. +- 보안 민감 URL은 configured external origin을 우선 사용한다. + +- [ ] **Step 1: Write the failing test** + +```java +class ExternalUriBuilderTest { + @org.junit.jupiter.api.Test + void appliesDevApiPrefixExactlyOnce() { + var builder = new ExternalUriBuilder( + new ExternalOrigin("https", "hyeonworks.com", 443), + new ExternalPrefix("/dev-api") + ); + + org.junit.jupiter.api.Assertions.assertEquals( + "https://hyeonworks.com/dev-api/v1/documents/d1", + builder.absolute("/v1/documents/d1").toString() + ); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the expected failure** + +Run: `./gradlew :modules:web:web-contract:test --tests '*ExternalUriBuilderTest'` + +Expected: FAIL because no controlled external URI builder exists. + +- [ ] **Step 3: Implement the minimum production contract** + +```java +public final class ExternalUriBuilder { + private final ExternalOrigin origin; + private final ExternalPrefix prefix; + + public java.net.URI absolute(String applicationPath) { + var canonical = applicationPath.startsWith("/") + ? applicationPath + : "/" + applicationPath; + var path = prefix.value() + canonical; + return java.net.URI.create( + origin.scheme() + "://" + origin.authority() + path + ); + } +} +``` + +- [ ] **Step 4: Run the task test and its module contract suite** + +Run: `./gradlew :modules:web:web-contract:test` + +Expected: PASS with no skipped contract assertions and no new warnings. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/web/web-contract/src/main/java/io/backend/skeleton/web/http/ExternalUriBuilder.java' 'modules/web/web-contract/src/main/java/io/backend/skeleton/web/http/ExternalOrigin.java' 'modules/web/web-contract/src/main/java/io/backend/skeleton/web/http/ExternalPrefix.java' 'modules/web/web-contract/src/main/java/io/backend/skeleton/web/http/ExternalUriPolicy.java' 'modules/web/web-contract/src/test/java/io/backend/skeleton/web/http/ExternalUriBuilderTest.java' +git commit -m "feat(web): build trusted external URLs and route prefixes" +``` + +### Task 26: Path Major API Versioning + +**Files:** +- Create: `modules/web/web-versioning/src/main/java/io/backend/skeleton/web/versioning/ApiVersionPolicy.java` +- Create: `modules/web/web-versioning/src/main/java/io/backend/skeleton/web/versioning/PathApiVersionResolver.java` +- Create: `modules/web/web-versioning/src/main/java/io/backend/skeleton/web/versioning/UnsupportedApiVersionException.java` +- Create: `modules/web/web-versioning/src/main/java/io/backend/skeleton/web/versioning/ApiVersionCatalog.java` +- Test: `modules/web/web-versioning/src/test/java/io/backend/skeleton/web/versioning/PathApiVersionResolverTest.java` + +**Interfaces:** +- Consumes: Task 2 ApiMajorVersion and Task 9 canonical URI +- Produces: `/api/v{major}` version resolution with registered versions and no minor/patch URL versioning + +**Implementation requirements:** +- Breaking change만 major path version을 올린다. +- Minor·patch evolution은 additive compatibility로 유지한다. +- Unknown major version을 404 또는 catalog-defined problem으로 일관되게 처리한다. + +- [ ] **Step 1: Write the failing test** + +```java +class PathApiVersionResolverTest { + @org.junit.jupiter.api.Test + void resolvesMajorVersionFromCanonicalPath() { + var resolver = new PathApiVersionResolver( + new ApiVersionCatalog(java.util.Set.of(new ApiMajorVersion(1))) + ); + org.junit.jupiter.api.Assertions.assertEquals( + new ApiMajorVersion(1), + resolver.resolve("/api/v1/documents") + ); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the expected failure** + +Run: `./gradlew :modules:web:web-versioning:test --tests '*PathApiVersionResolverTest'` + +Expected: FAIL because API version resolution is absent. + +- [ ] **Step 3: Implement the minimum production contract** + +```java +public final class PathApiVersionResolver { + private static final java.util.regex.Pattern PATTERN = + java.util.regex.Pattern.compile("^/api/v([1-9][0-9]*)(?:/|$)"); + + public ApiMajorVersion resolve(String path) { + var matcher = PATTERN.matcher(path); + if (!matcher.find()) { + throw new UnsupportedApiVersionException(path); + } + var version = new ApiMajorVersion(Integer.parseInt(matcher.group(1))); + return catalog.requireSupported(version); + } +} +``` + +- [ ] **Step 4: Run the task test and its module contract suite** + +Run: `./gradlew :modules:web:web-versioning:test` + +Expected: PASS with no skipped contract assertions and no new warnings. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/web/web-versioning/src/main/java/io/backend/skeleton/web/versioning/ApiVersionPolicy.java' 'modules/web/web-versioning/src/main/java/io/backend/skeleton/web/versioning/PathApiVersionResolver.java' 'modules/web/web-versioning/src/main/java/io/backend/skeleton/web/versioning/UnsupportedApiVersionException.java' 'modules/web/web-versioning/src/main/java/io/backend/skeleton/web/versioning/ApiVersionCatalog.java' 'modules/web/web-versioning/src/test/java/io/backend/skeleton/web/versioning/PathApiVersionResolverTest.java' +git commit -m "feat(web): add path-based major API versioning" +``` + +### Task 27: Deprecation·Sunset·Link Header Policy + +**Files:** +- Create: `modules/web/web-versioning/src/main/java/io/backend/skeleton/web/versioning/ApiDeprecationPolicy.java` +- Create: `modules/web/web-versioning/src/main/java/io/backend/skeleton/web/versioning/DeprecationHeaderWriter.java` +- Create: `modules/web/web-versioning/src/main/java/io/backend/skeleton/web/versioning/DeprecatedRoute.java` +- Create: `modules/web/web-versioning/src/main/java/io/backend/skeleton/web/versioning/SunsetViolationException.java` +- Test: `modules/web/web-versioning/src/test/java/io/backend/skeleton/web/versioning/DeprecationHeaderWriterTest.java` + +**Interfaces:** +- Consumes: Task 26 API version catalog and Task 10 header contract +- Produces: RFC-style Deprecation, Sunset, and Link headers for registered routes + +**Implementation requirements:** +- Deprecated route는 documentation link와 optional sunset date를 명시한다. +- Sunset 이후 route removal은 usage, OpenAPI diff, consumer contract, rollback gate를 통과해야 한다. +- 과거 시각의 deprecation/sunset metadata를 잘못 등록하면 startup 실패한다. + +- [ ] **Step 1: Write the failing test** + +```java +class DeprecationHeaderWriterTest { + @org.junit.jupiter.api.Test + void writesDeprecationSunsetAndDocumentationLink() { + var route = DeprecatedRoute.fixture( + java.time.Instant.parse("2026-09-01T00:00:00Z"), + java.time.Instant.parse("2027-03-01T00:00:00Z"), + java.net.URI.create("https://hyeonworks.com/docs/migrate-v1") + ); + var headers = new DeprecationHeaderWriter().headers(route); + + org.junit.jupiter.api.Assertions.assertTrue(headers.containsKey("Deprecation")); + org.junit.jupiter.api.Assertions.assertTrue(headers.containsKey("Sunset")); + org.junit.jupiter.api.Assertions.assertTrue(headers.get("Link").contains("deprecation")); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the expected failure** + +Run: `./gradlew :modules:web:web-versioning:test --tests '*DeprecationHeaderWriterTest'` + +Expected: FAIL because deprecation header support is missing. + +- [ ] **Step 3: Implement the minimum production contract** + +```java +public final class DeprecationHeaderWriter { + public java.util.Map headers(DeprecatedRoute route) { + var result = new java.util.LinkedHashMap(); + result.put("Deprecation", "@" + route.deprecatedAt().getEpochSecond()); + route.sunsetAt().ifPresent(value -> + result.put("Sunset", java.time.format.DateTimeFormatter.RFC_1123_DATE_TIME + .format(value.atZone(java.time.ZoneOffset.UTC)))); + result.put("Link", "<" + route.documentation() + ">; rel=\"deprecation\""); + return java.util.Map.copyOf(result); + } +} +``` + +- [ ] **Step 4: Run the task test and its module contract suite** + +Run: `./gradlew :modules:web:web-versioning:test` + +Expected: PASS with no skipped contract assertions and no new warnings. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/web/web-versioning/src/main/java/io/backend/skeleton/web/versioning/ApiDeprecationPolicy.java' 'modules/web/web-versioning/src/main/java/io/backend/skeleton/web/versioning/DeprecationHeaderWriter.java' 'modules/web/web-versioning/src/main/java/io/backend/skeleton/web/versioning/DeprecatedRoute.java' 'modules/web/web-versioning/src/main/java/io/backend/skeleton/web/versioning/SunsetViolationException.java' 'modules/web/web-versioning/src/test/java/io/backend/skeleton/web/versioning/DeprecationHeaderWriterTest.java' +git commit -m "feat(web): add deprecation and sunset header policy" +``` + +### Task 28: Runtime Route Inventory와 Contract 검증 + +**Files:** +- Create: `modules/web/web-admin/src/main/java/io/backend/skeleton/web/admin/route/WebRouteContract.java` +- Create: `modules/web/web-admin/src/main/java/io/backend/skeleton/web/admin/route/WebRouteInventory.java` +- Create: `modules/web/web-admin/src/main/java/io/backend/skeleton/web/admin/route/SpringMvcRouteInventoryCollector.java` +- Create: `modules/web/web-admin/src/main/java/io/backend/skeleton/web/admin/route/SpringWebFluxRouteInventoryCollector.java` +- Create: `modules/web/web-admin/src/main/java/io/backend/skeleton/web/admin/route/RouteInventoryMismatchException.java` +- Test: `modules/web/web-admin/src/test/java/io/backend/skeleton/web/admin/route/WebRouteInventoryTest.java` + +**Interfaces:** +- Consumes: Tasks 5 operation catalog, 26 versioning, and 27 deprecation metadata +- Produces: bounded route inventory with routeId, operationName, version, method, path, consumes, produces, deprecated, and sunset + +**Implementation requirements:** +- 모든 공개 route는 등록된 operation name을 가진다. +- 동일 method+path+version 중복을 거부한다. +- Runtime route inventory와 승인된 manifest가 다르면 Release Gate가 실패한다. + +- [ ] **Step 1: Write the failing test** + +```java +class WebRouteInventoryTest { + @org.junit.jupiter.api.Test + void rejectsDuplicateMethodPathAndVersion() { + var inventory = new WebRouteInventory(); + var route = WebRouteContract.fixture("GET", "/api/v1/documents/{id}", 1); + inventory.add(route); + + org.junit.jupiter.api.Assertions.assertThrows( + RouteInventoryMismatchException.class, + () -> inventory.add(route) + ); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the expected failure** + +Run: `./gradlew :modules:web:web-admin:test --tests '*WebRouteInventoryTest'` + +Expected: FAIL because route inventory contracts are missing. + +- [ ] **Step 3: Implement the minimum production contract** + +```java +public record WebRouteContract( + WebRouteId routeId, + WebOperationName operationName, + ApiMajorVersion apiVersion, + String method, + String pathTemplate, + java.util.Set consumes, + java.util.Set produces, + boolean deprecated, + java.util.Optional sunsetAt) { +} + +public final class WebRouteInventory { + private final java.util.Map routes = new java.util.LinkedHashMap<>(); + + public void add(WebRouteContract route) { + var key = route.apiVersion().value() + ":" + route.method() + ":" + route.pathTemplate(); + if (routes.putIfAbsent(key, route) != null) { + throw new RouteInventoryMismatchException(key); + } + } +} +``` + +- [ ] **Step 4: Run the task test and its module contract suite** + +Run: `./gradlew :modules:web:web-admin:test` + +Expected: PASS with no skipped contract assertions and no new warnings. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/web/web-admin/src/main/java/io/backend/skeleton/web/admin/route/WebRouteContract.java' 'modules/web/web-admin/src/main/java/io/backend/skeleton/web/admin/route/WebRouteInventory.java' 'modules/web/web-admin/src/main/java/io/backend/skeleton/web/admin/route/SpringMvcRouteInventoryCollector.java' 'modules/web/web-admin/src/main/java/io/backend/skeleton/web/admin/route/SpringWebFluxRouteInventoryCollector.java' 'modules/web/web-admin/src/main/java/io/backend/skeleton/web/admin/route/RouteInventoryMismatchException.java' 'modules/web/web-admin/src/test/java/io/backend/skeleton/web/admin/route/WebRouteInventoryTest.java' +git commit -m "feat(web): inventory and validate runtime routes" +``` + +### Task 29: OpenAPI 3.1.2 Snapshot 생성 + +**Files:** +- Create: `modules/web/web-openapi/src/main/java/io/backend/skeleton/web/openapi/WebOpenApiProfile.java` +- Create: `modules/web/web-openapi/src/main/java/io/backend/skeleton/web/openapi/WebOpenApiCustomizer.java` +- Create: `modules/web/web-openapi/src/main/java/io/backend/skeleton/web/openapi/ProblemSchemaContributor.java` +- Create: `modules/web/web-openapi/src/main/java/io/backend/skeleton/web/openapi/CursorSchemaContributor.java` +- Create: `modules/web/web-openapi/src/main/resources/openapi/web-platform-components.yaml` +- Test: `modules/web/web-openapi/src/test/java/io/backend/skeleton/web/openapi/WebOpenApiSnapshotTest.java` + +**Interfaces:** +- Consumes: Tasks 7 wire manifest, 11 problem catalog, 26 versioning, and 28 route inventory +- Produces: OpenAPI 3.1.2 generation with stable problem, pagination, security, validation, and deprecation schemas + +**Implementation requirements:** +- OpenAPI 3.1.2가 Stable artifact다. +- Problem Details status/code catalog가 문서와 runtime catalog에서 일치한다. +- Swagger UI는 Local/Dev authenticated profile 외 기본 비활성이다. + +- [ ] **Step 1: Write the failing test** + +```java +class WebOpenApiSnapshotTest { + @org.junit.jupiter.api.Test + void generatedDocumentUsesOpenApi312AndProblemSchema() { + var document = WebOpenApiProfile.standard().generate(); + org.junit.jupiter.api.Assertions.assertEquals("3.1.2", document.getOpenapi()); + org.junit.jupiter.api.Assertions.assertNotNull( + document.getComponents().getSchemas().get("Problem") + ); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the expected failure** + +Run: `./gradlew :modules:web:web-openapi:test --tests '*WebOpenApiSnapshotTest'` + +Expected: FAIL because OpenAPI profile and contributors do not exist. + +- [ ] **Step 3: Implement the minimum production contract** + +```java +public final class WebOpenApiProfile { + public io.swagger.v3.oas.models.OpenAPI generate() { + var api = new io.swagger.v3.oas.models.OpenAPI(); + api.setOpenapi("3.1.2"); + api.setComponents(new io.swagger.v3.oas.models.Components() + .addSchemas("Problem", ProblemSchemaContributor.schema()) + .addSchemas("CursorPage", CursorSchemaContributor.schema())); + return api; + } +} +``` + +- [ ] **Step 4: Run the task test and its module contract suite** + +Run: `./gradlew :modules:web:web-openapi:test` + +Expected: PASS with no skipped contract assertions and no new warnings. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/web/web-openapi/src/main/java/io/backend/skeleton/web/openapi/WebOpenApiProfile.java' 'modules/web/web-openapi/src/main/java/io/backend/skeleton/web/openapi/WebOpenApiCustomizer.java' 'modules/web/web-openapi/src/main/java/io/backend/skeleton/web/openapi/ProblemSchemaContributor.java' 'modules/web/web-openapi/src/main/java/io/backend/skeleton/web/openapi/CursorSchemaContributor.java' 'modules/web/web-openapi/src/main/resources/openapi/web-platform-components.yaml' 'modules/web/web-openapi/src/test/java/io/backend/skeleton/web/openapi/WebOpenApiSnapshotTest.java' +git commit -m "feat(web): generate approved OpenAPI 3.1.2 contract" +``` + +### Task 30: OpenAPI Breaking Diff와 Generated Client Compile Gate + +**Files:** +- Create: `modules/web/web-openapi/src/main/java/io/backend/skeleton/web/openapi/WebOpenApiBreakingPolicy.java` +- Create: `modules/web/web-openapi/src/main/java/io/backend/skeleton/web/openapi/WebOpenApiDiffResult.java` +- Create: `modules/web/web-openapi/src/main/java/io/backend/skeleton/web/openapi/WebOpenApiReleaseGate.java` +- Create: `modules/web/web-openapi/src/test/resources/openapi/released-v1.yaml` +- Create: `.github/workflows/web-openapi-contract.yml` +- Modify: `build.gradle.kts` +- Test: `modules/web/web-openapi/src/test/java/io/backend/skeleton/web/openapi/WebOpenApiReleaseGateTest.java` + +**Interfaces:** +- Consumes: Task 29 generated OpenAPI and Task 28 route inventory +- Produces: CI gate for lint, schema validation, breaking diff, route match, Problem inventory, and generated-client compilation + +**Implementation requirements:** +- 승인된 release snapshot과 현재 document를 비교한다. +- Breaking change는 major version release가 아니면 merge를 차단한다. +- Generated Java client compile을 실행해 source-level incompatibility를 검출한다. + +- [ ] **Step 1: Write the failing test** + +```java +class WebOpenApiReleaseGateTest { + @org.junit.jupiter.api.Test + void removingResponseFieldIsBreaking() { + var gate = new WebOpenApiReleaseGate(WebOpenApiBreakingPolicy.strict()); + var released = WebOpenApiFixtures.released(); + var current = WebOpenApiFixtures.withRemovedRequiredField(); + + org.junit.jupiter.api.Assertions.assertFalse( + gate.evaluate(released, current).allowed() + ); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the expected failure** + +Run: `./gradlew :modules:web:web-openapi:test --tests '*WebOpenApiReleaseGateTest'` + +Expected: FAIL because no OpenAPI release gate is implemented. + +- [ ] **Step 3: Implement the minimum production contract** + +```java +public final class WebOpenApiReleaseGate { + private final WebOpenApiBreakingPolicy policy; + + public WebOpenApiDiffResult evaluate( + io.swagger.v3.oas.models.OpenAPI released, + io.swagger.v3.oas.models.OpenAPI current) { + var changes = policy.compare(released, current); + return new WebOpenApiDiffResult(changes.isEmpty(), changes); + } +} +``` + +- [ ] **Step 4: Run the task test and its module contract suite** + +Run: `./gradlew webOpenApiContract` + +Expected: PASS with no skipped contract assertions and no new warnings. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/web/web-openapi/src/main/java/io/backend/skeleton/web/openapi/WebOpenApiBreakingPolicy.java' 'modules/web/web-openapi/src/main/java/io/backend/skeleton/web/openapi/WebOpenApiDiffResult.java' 'modules/web/web-openapi/src/main/java/io/backend/skeleton/web/openapi/WebOpenApiReleaseGate.java' 'modules/web/web-openapi/src/test/resources/openapi/released-v1.yaml' '.github/workflows/web-openapi-contract.yml' 'build.gradle.kts' 'modules/web/web-openapi/src/test/java/io/backend/skeleton/web/openapi/WebOpenApiReleaseGateTest.java' +git commit -m "build(web): add OpenAPI breaking and client compile gate" +``` + +### Task 31: Sort·Filter·Projection Catalog + +**Files:** +- Create: `modules/web/web-pagination/src/main/java/io/backend/skeleton/web/pagination/SortFieldCatalog.java` +- Create: `modules/web/web-pagination/src/main/java/io/backend/skeleton/web/pagination/FilterFieldCatalog.java` +- Create: `modules/web/web-pagination/src/main/java/io/backend/skeleton/web/pagination/FilterOperatorCatalog.java` +- Create: `modules/web/web-pagination/src/main/java/io/backend/skeleton/web/pagination/ProjectionProfileCatalog.java` +- Create: `modules/web/web-pagination/src/main/java/io/backend/skeleton/web/pagination/InMemoryCollectionQueryCatalog.java` +- Test: `modules/web/web-pagination/src/test/java/io/backend/skeleton/web/pagination/CollectionQueryCatalogTest.java` + +**Interfaces:** +- Consumes: Task 5 operation profiles and storage-layer query profile identifiers +- Produces: allowlisted external query vocabulary mapped to internal query descriptors + +**Implementation requirements:** +- 사용자 입력 sort 이름을 DB column, JPQL, Mongo field path로 직접 전달하지 않는다. +- Filter operator와 projection include는 등록 catalog에서만 선택한다. +- Unknown field/operator/profile은 400 Problem으로 변환 가능한 안정 예외를 낸다. + +- [ ] **Step 1: Write the failing test** + +```java +class CollectionQueryCatalogTest { + @org.junit.jupiter.api.Test + void rejectsUnknownSortField() { + var catalog = InMemoryCollectionQueryCatalog.standard(); + org.junit.jupiter.api.Assertions.assertThrows( + IllegalArgumentException.class, + () -> catalog.sortFields().resolve("drop_table") + ); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the expected failure** + +Run: `./gradlew :modules:web:web-pagination:test --tests '*CollectionQueryCatalogTest'` + +Expected: FAIL because collection query catalogs do not exist. + +- [ ] **Step 3: Implement the minimum production contract** + +```java +public interface SortFieldCatalog { + SortField resolve(String externalName); +} + +public final class InMemorySortFieldCatalog implements SortFieldCatalog { + private final java.util.Map fields; + + public SortField resolve(String externalName) { + var result = fields.get(externalName); + if (result == null) { + throw new IllegalArgumentException("unsupported sort field"); + } + return result; + } +} +``` + +- [ ] **Step 4: Run the task test and its module contract suite** + +Run: `./gradlew :modules:web:web-pagination:test` + +Expected: PASS with no skipped contract assertions and no new warnings. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/web/web-pagination/src/main/java/io/backend/skeleton/web/pagination/SortFieldCatalog.java' 'modules/web/web-pagination/src/main/java/io/backend/skeleton/web/pagination/FilterFieldCatalog.java' 'modules/web/web-pagination/src/main/java/io/backend/skeleton/web/pagination/FilterOperatorCatalog.java' 'modules/web/web-pagination/src/main/java/io/backend/skeleton/web/pagination/ProjectionProfileCatalog.java' 'modules/web/web-pagination/src/main/java/io/backend/skeleton/web/pagination/InMemoryCollectionQueryCatalog.java' 'modules/web/web-pagination/src/test/java/io/backend/skeleton/web/pagination/CollectionQueryCatalogTest.java' +git commit -m "feat(web): add allowlisted collection query catalogs" +``` + +### Task 32: HMAC 인증 Keyset Cursor + +**Files:** +- Create: `modules/web/web-pagination/src/main/java/io/backend/skeleton/web/pagination/WebCursorPayload.java` +- Create: `modules/web/web-pagination/src/main/java/io/backend/skeleton/web/pagination/WebCursorCodec.java` +- Create: `modules/web/web-pagination/src/main/java/io/backend/skeleton/web/pagination/HmacWebCursorCodec.java` +- Create: `modules/web/web-pagination/src/main/java/io/backend/skeleton/web/pagination/WebCursorKeyRing.java` +- Create: `modules/web/web-pagination/src/main/java/io/backend/skeleton/web/pagination/WebCursorException.java` +- Test: `modules/web/web-pagination/src/test/java/io/backend/skeleton/web/pagination/HmacWebCursorCodecTest.java` + +**Interfaces:** +- Consumes: Task 7 wire manifest and Task 31 query catalogs +- Produces: versioned, query-profile-bound, filter-bound, signed keyset cursor codec + +**Implementation requirements:** +- Cursor는 opaque하며 Base64만으로 신뢰하지 않는다. +- query profile, sort values, unique tie-breaker, filter fingerprint, issuedAt, keyId를 인증한다. +- Unknown version, wrong profile, filter mismatch, bad MAC을 거부한다. + +- [ ] **Step 1: Write the failing test** + +```java +class HmacWebCursorCodecTest { + @org.junit.jupiter.api.Test + void detectsTamperedCursor() { + var codec = HmacWebCursorCodec.fixture(); + var token = codec.encode(WebCursorFixtures.payload()); + var tampered = token.substring(0, token.length() - 1) + "A"; + + org.junit.jupiter.api.Assertions.assertThrows( + WebCursorException.class, + () -> codec.decode(tampered) + ); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the expected failure** + +Run: `./gradlew :modules:web:web-pagination:test --tests '*HmacWebCursorCodecTest'` + +Expected: FAIL because signed cursor support is absent. + +- [ ] **Step 3: Implement the minimum production contract** + +```java +public interface WebCursorCodec { + String encode(WebCursorPayload payload); + WebCursorPayload decode(String token); +} + +public final class HmacWebCursorCodec implements WebCursorCodec { + private final javax.crypto.SecretKey key; + private final com.fasterxml.jackson.databind.ObjectMapper mapper; + + public String encode(WebCursorPayload payload) { + var bytes = write(payload); + var mac = hmac(bytes); + return java.util.Base64.getUrlEncoder().withoutPadding() + .encodeToString(concat(bytes, mac)); + } + + public WebCursorPayload decode(String token) { + var decoded = java.util.Base64.getUrlDecoder().decode(token); + var split = splitPayloadAndMac(decoded); + if (!java.security.MessageDigest.isEqual(hmac(split.payload()), split.mac())) { + throw new WebCursorException("cursor signature mismatch"); + } + return read(split.payload()); + } +} +``` + +- [ ] **Step 4: Run the task test and its module contract suite** + +Run: `./gradlew :modules:web:web-pagination:test` + +Expected: PASS with no skipped contract assertions and no new warnings. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/web/web-pagination/src/main/java/io/backend/skeleton/web/pagination/WebCursorPayload.java' 'modules/web/web-pagination/src/main/java/io/backend/skeleton/web/pagination/WebCursorCodec.java' 'modules/web/web-pagination/src/main/java/io/backend/skeleton/web/pagination/HmacWebCursorCodec.java' 'modules/web/web-pagination/src/main/java/io/backend/skeleton/web/pagination/WebCursorKeyRing.java' 'modules/web/web-pagination/src/main/java/io/backend/skeleton/web/pagination/WebCursorException.java' 'modules/web/web-pagination/src/test/java/io/backend/skeleton/web/pagination/HmacWebCursorCodecTest.java' +git commit -m "feat(web): add signed keyset cursor codec" +``` + +### Task 33: Collection Request Parser와 Page Hard Limit + +**Files:** +- Create: `modules/web/web-pagination/src/main/java/io/backend/skeleton/web/pagination/WebCollectionRequest.java` +- Create: `modules/web/web-pagination/src/main/java/io/backend/skeleton/web/pagination/WebCollectionRequestParser.java` +- Create: `modules/web/web-pagination/src/main/java/io/backend/skeleton/web/pagination/WebPageSizePolicy.java` +- Create: `modules/web/web-pagination/src/main/java/io/backend/skeleton/web/pagination/FilterFingerprint.java` +- Test: `modules/web/web-pagination/src/test/java/io/backend/skeleton/web/pagination/WebCollectionRequestParserTest.java` + +**Interfaces:** +- Consumes: Tasks 31–32 catalogs and cursor codec +- Produces: bounded Page, Slice, and keyset collection requests with deterministic filter fingerprint + +**Implementation requirements:** +- Standard default limit 50, hard maximum 200을 platform profile로 제공한다. +- Cursor 사용 시 sort/query profile/filter fingerprint를 다시 검증한다. +- `totalCount`는 명시 profile 외 계산하지 않는다. + +- [ ] **Step 1: Write the failing test** + +```java +class WebCollectionRequestParserTest { + @org.junit.jupiter.api.Test + void rejectsLimitAboveHardMaximum() { + var parser = WebCollectionRequestParser.standard(); + org.junit.jupiter.api.Assertions.assertThrows( + IllegalArgumentException.class, + () -> parser.parse(java.util.Map.of("limit", "201")) + ); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the expected failure** + +Run: `./gradlew :modules:web:web-pagination:test --tests '*WebCollectionRequestParserTest'` + +Expected: FAIL because bounded collection request parsing is missing. + +- [ ] **Step 3: Implement the minimum production contract** + +```java +public record WebPageSizePolicy(int defaultSize, int hardMaximum) { + public int normalize(Integer requested) { + int value = requested == null ? defaultSize : requested; + if (value < 1 || value > hardMaximum) { + throw new IllegalArgumentException("invalid page size"); + } + return value; + } +} + +public final class WebCollectionRequestParser { + private final WebPageSizePolicy pageSizePolicy; + private final WebCursorCodec cursorCodec; + + public WebCollectionRequest parse(java.util.Map input) { + var limit = pageSizePolicy.normalize(parseInteger(input.get("limit"))); + var cursor = java.util.Optional.ofNullable(input.get("cursor")) + .map(cursorCodec::decode); + return new WebCollectionRequest(limit, cursor); + } +} +``` + +- [ ] **Step 4: Run the task test and its module contract suite** + +Run: `./gradlew :modules:web:web-pagination:test` + +Expected: PASS with no skipped contract assertions and no new warnings. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/web/web-pagination/src/main/java/io/backend/skeleton/web/pagination/WebCollectionRequest.java' 'modules/web/web-pagination/src/main/java/io/backend/skeleton/web/pagination/WebCollectionRequestParser.java' 'modules/web/web-pagination/src/main/java/io/backend/skeleton/web/pagination/WebPageSizePolicy.java' 'modules/web/web-pagination/src/main/java/io/backend/skeleton/web/pagination/FilterFingerprint.java' 'modules/web/web-pagination/src/test/java/io/backend/skeleton/web/pagination/WebCollectionRequestParserTest.java' +git commit -m "feat(web): parse bounded collection requests" +``` + +### Task 34: ETag·Conditional GET 계약 + +**Files:** +- Create: `modules/web/web-contract/src/main/java/io/backend/skeleton/web/conditional/EntityTag.java` +- Create: `modules/web/web-contract/src/main/java/io/backend/skeleton/web/conditional/EntityTagCodec.java` +- Create: `modules/web/web-contract/src/main/java/io/backend/skeleton/web/conditional/ConditionalReadEvaluator.java` +- Create: `modules/web/web-contract/src/main/java/io/backend/skeleton/web/conditional/ConditionalReadDecision.java` +- Test: `modules/web/web-contract/src/test/java/io/backend/skeleton/web/conditional/ConditionalReadEvaluatorTest.java` + +**Interfaces:** +- Consumes: Task 10 status/header contract and application version mapper SPI +- Produces: strong/weak ETag parsing and GET/HEAD If-None-Match evaluation + +**Implementation requirements:** +- Mutation concurrency에는 strong ETag만 사용한다. +- GET·HEAD의 validator match는 304와 body 없음으로 매핑한다. +- ETag는 storage/provider ETag와 같은 타입으로 가정하지 않는다. + +- [ ] **Step 1: Write the failing test** + +```java +class ConditionalReadEvaluatorTest { + @org.junit.jupiter.api.Test + void matchingIfNoneMatchReturnsNotModified() { + var evaluator = new ConditionalReadEvaluator(); + var current = new EntityTag("\"v17\"", false); + + org.junit.jupiter.api.Assertions.assertEquals( + ConditionalReadDecision.NOT_MODIFIED, + evaluator.evaluate(java.util.List.of(current), current) + ); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the expected failure** + +Run: `./gradlew :modules:web:web-contract:test --tests '*ConditionalReadEvaluatorTest'` + +Expected: FAIL because conditional read evaluation is absent. + +- [ ] **Step 3: Implement the minimum production contract** + +```java +public record EntityTag(String value, boolean weak) { + public EntityTag { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("etag value required"); + } + } +} + +public enum ConditionalReadDecision { + SEND_REPRESENTATION, + NOT_MODIFIED +} + +public final class ConditionalReadEvaluator { + public ConditionalReadDecision evaluate( + java.util.List ifNoneMatch, + EntityTag current) { + return ifNoneMatch.stream().anyMatch(tag -> equivalentForRead(tag, current)) + ? ConditionalReadDecision.NOT_MODIFIED + : ConditionalReadDecision.SEND_REPRESENTATION; + } +} +``` + +- [ ] **Step 4: Run the task test and its module contract suite** + +Run: `./gradlew :modules:web:web-contract:test` + +Expected: PASS with no skipped contract assertions and no new warnings. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/web/web-contract/src/main/java/io/backend/skeleton/web/conditional/EntityTag.java' 'modules/web/web-contract/src/main/java/io/backend/skeleton/web/conditional/EntityTagCodec.java' 'modules/web/web-contract/src/main/java/io/backend/skeleton/web/conditional/ConditionalReadEvaluator.java' 'modules/web/web-contract/src/main/java/io/backend/skeleton/web/conditional/ConditionalReadDecision.java' 'modules/web/web-contract/src/test/java/io/backend/skeleton/web/conditional/ConditionalReadEvaluatorTest.java' +git commit -m "feat(web): add ETag and conditional read semantics" +``` + +### Task 35: If-Match·If-None-Match Mutation Precondition + +**Files:** +- Create: `modules/web/web-contract/src/main/java/io/backend/skeleton/web/conditional/HttpPrecondition.java` +- Create: `modules/web/web-contract/src/main/java/io/backend/skeleton/web/conditional/MutationPreconditionEvaluator.java` +- Create: `modules/web/web-contract/src/main/java/io/backend/skeleton/web/conditional/PreconditionDecision.java` +- Create: `modules/web/web-contract/src/main/java/io/backend/skeleton/web/conditional/PreconditionFailedException.java` +- Test: `modules/web/web-contract/src/test/java/io/backend/skeleton/web/conditional/MutationPreconditionEvaluatorTest.java` + +**Interfaces:** +- Consumes: Task 34 EntityTag contract and application expected-version mapper +- Produces: 412-producing strong If-Match and create-only If-None-Match `*` policies + +**Implementation requirements:** +- PUT/PATCH/DELETE If-Match mismatch는 412다. +- HTTP conditional header가 없는 업무 상태 충돌은 409다. +- Create-only PUT의 existing resource는 412다. + +- [ ] **Step 1: Write the failing test** + +```java +class MutationPreconditionEvaluatorTest { + @org.junit.jupiter.api.Test + void mismatchIsPreconditionFailed() { + var evaluator = new MutationPreconditionEvaluator(); + org.junit.jupiter.api.Assertions.assertEquals( + PreconditionDecision.FAILED, + evaluator.evaluateMatch( + new EntityTag("\"v16\"", false), + new EntityTag("\"v17\"", false) + ) + ); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the expected failure** + +Run: `./gradlew :modules:web:web-contract:test --tests '*MutationPreconditionEvaluatorTest'` + +Expected: FAIL because mutation preconditions are not implemented. + +- [ ] **Step 3: Implement the minimum production contract** + +```java +public sealed interface HttpPrecondition { + record None() implements HttpPrecondition {} + record Match(EntityTag etag) implements HttpPrecondition {} + record NoneMatchAny() implements HttpPrecondition {} +} + +public final class MutationPreconditionEvaluator { + public PreconditionDecision evaluateMatch(EntityTag expected, EntityTag current) { + if (expected.weak() || current.weak()) { + return PreconditionDecision.FAILED; + } + return expected.value().equals(current.value()) + ? PreconditionDecision.SATISFIED + : PreconditionDecision.FAILED; + } +} +``` + +- [ ] **Step 4: Run the task test and its module contract suite** + +Run: `./gradlew :modules:web:web-contract:test` + +Expected: PASS with no skipped contract assertions and no new warnings. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/web/web-contract/src/main/java/io/backend/skeleton/web/conditional/HttpPrecondition.java' 'modules/web/web-contract/src/main/java/io/backend/skeleton/web/conditional/MutationPreconditionEvaluator.java' 'modules/web/web-contract/src/main/java/io/backend/skeleton/web/conditional/PreconditionDecision.java' 'modules/web/web-contract/src/main/java/io/backend/skeleton/web/conditional/PreconditionFailedException.java' 'modules/web/web-contract/src/test/java/io/backend/skeleton/web/conditional/MutationPreconditionEvaluatorTest.java' +git commit -m "feat(web): enforce conditional mutation preconditions" +``` + +### Task 36: MVC·WebFlux Application·Response Evidence Instrumentation + +**Files:** +- Create: `modules/web/web-mvc/src/main/java/io/backend/skeleton/web/mvc/evidence/WebMvcApplicationEvidenceInterceptor.java` +- Create: `modules/web/web-mvc/src/main/java/io/backend/skeleton/web/mvc/evidence/EvidenceAwareHttpServletResponse.java` +- Create: `modules/web/web-webflux/src/main/java/io/backend/skeleton/web/webflux/evidence/WebFluxApplicationEvidenceFilter.java` +- Create: `modules/web/web-webflux/src/main/java/io/backend/skeleton/web/webflux/evidence/EvidenceAwareServerHttpResponse.java` +- Test: `modules/web/web-testkit-contract/src/test/java/io/backend/skeleton/web/testkit/evidence/WebResponseEvidenceContractTest.java` + +**Interfaces:** +- Consumes: Task 4 evidence tracker, Tasks 16 and 20 stack contexts +- Produces: application start/rollback/commit markers and response header/partial/local-write evidence for both stacks + +**Implementation requirements:** +- Controller 진입을 Application commit으로 표시하지 않는다. +- Application Use Case가 명시적 commit evidence를 반환하거나 transaction hook이 확인한 경우에만 committed로 표시한다. +- response flush/write 완료를 client observation으로 승격하지 않는다. + +- [ ] **Step 1: Write the failing test** + +```java +class WebResponseEvidenceContractTest { + @org.junit.jupiter.api.Test + void localWriteCompletionDoesNotBecomeClientObserved() { + var tracker = WebExecutionEvidenceTracker.received(); + WebResponseEvidenceContract.markHeadersCommitted(tracker); + WebResponseEvidenceContract.markLocalWriteCompleted(tracker); + + org.junit.jupiter.api.Assertions.assertEquals( + WebResponseEvidence.RESPONSE_WRITE_COMPLETED_LOCALLY, + tracker.snapshot().responseEvidence() + ); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the expected failure** + +Run: `./gradlew :modules:web:web-testkit-contract:test --tests '*WebResponseEvidenceContractTest'` + +Expected: FAIL because stack-neutral response evidence instrumentation is absent. + +- [ ] **Step 3: Implement the minimum production contract** + +```java +public interface ApplicationCommitEvidence { + String transactionReference(); +} + +public final class WebApplicationEvidenceRecorder { + public void started(WebExecutionEvidenceTracker tracker) { + tracker.markApplicationStarted(); + } + + public void committed( + WebExecutionEvidenceTracker tracker, + ApplicationCommitEvidence evidence) { + java.util.Objects.requireNonNull(evidence); + tracker.markApplicationCommitted(); + } + + public void rolledBack(WebExecutionEvidenceTracker tracker) { + tracker.markApplicationRolledBack(); + } +} +``` + +- [ ] **Step 4: Run the task test and its module contract suite** + +Run: `./gradlew :modules:web:web-testkit-contract:test :modules:web:web-mvc:test :modules:web:web-webflux:test` + +Expected: PASS with no skipped contract assertions and no new warnings. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/web/web-mvc/src/main/java/io/backend/skeleton/web/mvc/evidence/WebMvcApplicationEvidenceInterceptor.java' 'modules/web/web-mvc/src/main/java/io/backend/skeleton/web/mvc/evidence/EvidenceAwareHttpServletResponse.java' 'modules/web/web-webflux/src/main/java/io/backend/skeleton/web/webflux/evidence/WebFluxApplicationEvidenceFilter.java' 'modules/web/web-webflux/src/main/java/io/backend/skeleton/web/webflux/evidence/EvidenceAwareServerHttpResponse.java' 'modules/web/web-testkit-contract/src/test/java/io/backend/skeleton/web/testkit/evidence/WebResponseEvidenceContractTest.java' +git commit -m "feat(web): instrument application and response evidence" +``` + +### Task 37: Idempotency Core 상태·SPI·Replay Snapshot + +**Files:** +- Create: `modules/web/web-idempotency/src/main/java/io/backend/skeleton/web/idempotency/IdempotencyPolicy.java` +- Create: `modules/web/web-idempotency/src/main/java/io/backend/skeleton/web/idempotency/IdempotencyKey.java` +- Create: `modules/web/web-idempotency/src/main/java/io/backend/skeleton/web/idempotency/IdempotencyScope.java` +- Create: `modules/web/web-idempotency/src/main/java/io/backend/skeleton/web/idempotency/IdempotencyState.java` +- Create: `modules/web/web-idempotency/src/main/java/io/backend/skeleton/web/idempotency/IdempotencyStore.java` +- Create: `modules/web/web-idempotency/src/main/java/io/backend/skeleton/web/idempotency/IdempotencyClaim.java` +- Create: `modules/web/web-idempotency/src/main/java/io/backend/skeleton/web/idempotency/IdempotencyRecord.java` +- Create: `modules/web/web-idempotency/src/main/java/io/backend/skeleton/web/idempotency/ResponseSnapshot.java` +- Test: `modules/web/web-idempotency/src/test/java/io/backend/skeleton/web/idempotency/IdempotencyStoreContractTest.java` + +**Interfaces:** +- Consumes: Task 5 operation profile, Task 36 commit evidence, existing actor/tenant fingerprints +- Produces: store-neutral claim, application-committed, replay-complete, retryable-failure, completion-unknown, and expiry semantics + +**Implementation requirements:** +- 동일 scope의 concurrent claim은 하나만 획득한다. +- Idempotency state는 `PROCESSING → APPLICATION_COMMITTED → COMPLETED_REPLAYABLE` 순서를 보존한다. +- `COMPLETION_UNKNOWN`을 자동 재실행 가능한 상태로 취급하지 않는다. + +- [ ] **Step 1: Write the failing test** + +```java +abstract class IdempotencyStoreContractTest { + protected abstract IdempotencyStore store(); + + @org.junit.jupiter.api.Test + void oneConcurrentClaimWins() throws Exception { + var scope = IdempotencyFixtures.scope(); + var fingerprint = IdempotencyFixtures.fingerprint(); + var pool = java.util.concurrent.Executors.newFixedThreadPool(2); + + var results = pool.invokeAll(java.util.List.of( + () -> store().claim(scope, fingerprint, IdempotencyFixtures.expiry()), + () -> store().claim(scope, fingerprint, IdempotencyFixtures.expiry()) + )); + + long acquired = results.stream() + .map(result -> { + try { return result.get(); } + catch (Exception e) { throw new RuntimeException(e); } + }) + .filter(IdempotencyClaim::acquired) + .count(); + org.junit.jupiter.api.Assertions.assertEquals(1, acquired); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the expected failure** + +Run: `./gradlew :modules:web:web-idempotency:test --tests '*IdempotencyStoreContractTest'` + +Expected: FAIL because the idempotency SPI and contract fixture do not exist. + +- [ ] **Step 3: Implement the minimum production contract** + +```java +public interface IdempotencyStore { + IdempotencyClaim claim( + IdempotencyScope scope, + RequestFingerprint fingerprint, + java.time.Instant expiresAt + ); + + void recordApplicationCommitted( + IdempotencyScope scope, + CommitEvidence evidence + ); + + void complete( + IdempotencyScope scope, + ResponseSnapshot snapshot, + java.time.Instant expiresAt + ); + + java.util.Optional find(IdempotencyScope scope); +} +``` + +- [ ] **Step 4: Run the task test and its module contract suite** + +Run: `./gradlew :modules:web:web-idempotency:test` + +Expected: PASS with no skipped contract assertions and no new warnings. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/web/web-idempotency/src/main/java/io/backend/skeleton/web/idempotency/IdempotencyPolicy.java' 'modules/web/web-idempotency/src/main/java/io/backend/skeleton/web/idempotency/IdempotencyKey.java' 'modules/web/web-idempotency/src/main/java/io/backend/skeleton/web/idempotency/IdempotencyScope.java' 'modules/web/web-idempotency/src/main/java/io/backend/skeleton/web/idempotency/IdempotencyState.java' 'modules/web/web-idempotency/src/main/java/io/backend/skeleton/web/idempotency/IdempotencyStore.java' 'modules/web/web-idempotency/src/main/java/io/backend/skeleton/web/idempotency/IdempotencyClaim.java' 'modules/web/web-idempotency/src/main/java/io/backend/skeleton/web/idempotency/IdempotencyRecord.java' 'modules/web/web-idempotency/src/main/java/io/backend/skeleton/web/idempotency/ResponseSnapshot.java' 'modules/web/web-idempotency/src/test/java/io/backend/skeleton/web/idempotency/IdempotencyStoreContractTest.java' +git commit -m "feat(web): define idempotency state and store contract" +``` + +### Task 38: Semantic Request Fingerprint + +**Files:** +- Create: `modules/web/web-idempotency/src/main/java/io/backend/skeleton/web/idempotency/RequestFingerprint.java` +- Create: `modules/web/web-idempotency/src/main/java/io/backend/skeleton/web/idempotency/SemanticRequestFingerprintFactory.java` +- Create: `modules/web/web-idempotency/src/main/java/io/backend/skeleton/web/idempotency/FingerprintHeaderPolicy.java` +- Create: `modules/web/web-idempotency/src/main/java/io/backend/skeleton/web/idempotency/DeterministicCommandEncoder.java` +- Test: `modules/web/web-idempotency/src/test/java/io/backend/skeleton/web/idempotency/SemanticRequestFingerprintFactoryTest.java` + +**Interfaces:** +- Consumes: Task 7 wire manifest, Task 8 strict mapper, Task 37 idempotency scope +- Produces: deterministic fingerprint over operation, normalized path identifiers, semantic DTO, and selected headers + +**Implementation requirements:** +- Raw JSON byte 순서·공백을 fingerprint source로 사용하지 않는다. +- Authorization, cookie, trace, request ID는 fingerprint에 포함하지 않는다. +- 동일 semantic DTO는 field order가 달라도 같은 fingerprint를 만든다. + +- [ ] **Step 1: Write the failing test** + +```java +class SemanticRequestFingerprintFactoryTest { + record Command(String title, int priority) {} + + @org.junit.jupiter.api.Test + void fieldOrderDoesNotChangeFingerprint() { + var factory = SemanticRequestFingerprintFactory.fixture(); + var one = factory.create( + new WebOperationName("documents.create"), + java.util.Map.of("projectId", "p1"), + new Command("doc", 1), + java.util.Map.of() + ); + var two = factory.create( + new WebOperationName("documents.create"), + new java.util.LinkedHashMap<>(java.util.Map.of("projectId", "p1")), + new Command("doc", 1), + java.util.Map.of() + ); + org.junit.jupiter.api.Assertions.assertEquals(one, two); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the expected failure** + +Run: `./gradlew :modules:web:web-idempotency:test --tests '*SemanticRequestFingerprintFactoryTest'` + +Expected: FAIL because semantic fingerprinting is missing. + +- [ ] **Step 3: Implement the minimum production contract** + +```java +public final class SemanticRequestFingerprintFactory { + private final DeterministicCommandEncoder encoder; + + public RequestFingerprint create( + WebOperationName operation, + java.util.Map normalizedPath, + Object command, + java.util.Map selectedHeaders) { + var digest = java.security.MessageDigest.getInstance("SHA-256"); + digest.update(operation.value().getBytes(java.nio.charset.StandardCharsets.UTF_8)); + digest.update(encoder.encode(normalizedPath)); + digest.update(encoder.encode(command)); + digest.update(encoder.encode(selectedHeaders)); + return new RequestFingerprint(java.util.HexFormat.of().formatHex(digest.digest())); + } +} +``` + +- [ ] **Step 4: Run the task test and its module contract suite** + +Run: `./gradlew :modules:web:web-idempotency:test` + +Expected: PASS with no skipped contract assertions and no new warnings. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/web/web-idempotency/src/main/java/io/backend/skeleton/web/idempotency/RequestFingerprint.java' 'modules/web/web-idempotency/src/main/java/io/backend/skeleton/web/idempotency/SemanticRequestFingerprintFactory.java' 'modules/web/web-idempotency/src/main/java/io/backend/skeleton/web/idempotency/FingerprintHeaderPolicy.java' 'modules/web/web-idempotency/src/main/java/io/backend/skeleton/web/idempotency/DeterministicCommandEncoder.java' 'modules/web/web-idempotency/src/test/java/io/backend/skeleton/web/idempotency/SemanticRequestFingerprintFactoryTest.java' +git commit -m "feat(web): fingerprint semantic mutation requests" +``` + +### Task 39: JPA Idempotency Commit Evidence Adapter + +**Files:** +- Create: `modules/web/web-idempotency-jpa/build.gradle.kts` +- Create: `modules/web/web-idempotency-jpa/src/main/java/io/backend/skeleton/web/idempotency/jpa/JpaIdempotencyRecord.java` +- Create: `modules/web/web-idempotency-jpa/src/main/java/io/backend/skeleton/web/idempotency/jpa/JpaIdempotencyStore.java` +- Create: `modules/web/web-idempotency-jpa/src/main/java/io/backend/skeleton/web/idempotency/jpa/JpaIdempotencyRepository.java` +- Create: `modules/web/web-idempotency-jpa/src/main/resources/db/migration/web/V001__web_idempotency.sql` +- Modify: `settings.gradle.kts` +- Test: `modules/web/web-idempotency-jpa/src/test/java/io/backend/skeleton/web/idempotency/jpa/JpaIdempotencyAtomicCommitIT.java` + +**Interfaces:** +- Consumes: Tasks 37–38 idempotency contracts and JPA platform transaction executor +- Produces: same-PostgreSQL-transaction business mutation plus idempotency APPLICATION_COMMITTED evidence + +**Implementation requirements:** +- DB-local mutation의 commit evidence는 business row와 같은 DB transaction에서 기록한다. +- Business commit 후 idempotency record가 없는 상태를 허용하지 않는다. +- Commit result unknown은 `COMPLETION_UNKNOWN`으로 남기고 자동 transaction 재실행하지 않는다. + +- [ ] **Step 1: Write the failing test** + +```java +@org.springframework.boot.test.context.SpringBootTest +class JpaIdempotencyAtomicCommitIT { + @org.junit.jupiter.api.Test + void businessMutationAndEvidenceRollbackTogether() { + var result = fixture.executeAndFailAfterBusinessMutation(); + + org.junit.jupiter.api.Assertions.assertFalse(result.businessRowExists()); + org.junit.jupiter.api.Assertions.assertTrue(result.idempotencyRecord().isEmpty()); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the expected failure** + +Run: `./gradlew :modules:web:web-idempotency-jpa:test --tests '*JpaIdempotencyAtomicCommitIT'` + +Expected: FAIL because the JPA adapter, schema, and atomic transaction integration are absent. + +- [ ] **Step 3: Implement the minimum production contract** + +```sql +CREATE TABLE web_idempotency_record ( + scope_hash varchar(128) PRIMARY KEY, + request_fingerprint varchar(128) NOT NULL, + state varchar(32) NOT NULL, + commit_reference varchar(128), + response_status integer, + response_headers jsonb, + response_body bytea, + created_at timestamptz NOT NULL, + expires_at timestamptz NOT NULL, + version bigint NOT NULL +); +``` + +- [ ] **Step 4: Run the task test and its module contract suite** + +Run: `./gradlew :modules:web:web-idempotency-jpa:test` + +Expected: PASS with no skipped contract assertions and no new warnings. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/web/web-idempotency-jpa/build.gradle.kts' 'modules/web/web-idempotency-jpa/src/main/java/io/backend/skeleton/web/idempotency/jpa/JpaIdempotencyRecord.java' 'modules/web/web-idempotency-jpa/src/main/java/io/backend/skeleton/web/idempotency/jpa/JpaIdempotencyStore.java' 'modules/web/web-idempotency-jpa/src/main/java/io/backend/skeleton/web/idempotency/jpa/JpaIdempotencyRepository.java' 'modules/web/web-idempotency-jpa/src/main/resources/db/migration/web/V001__web_idempotency.sql' 'settings.gradle.kts' 'modules/web/web-idempotency-jpa/src/test/java/io/backend/skeleton/web/idempotency/jpa/JpaIdempotencyAtomicCommitIT.java' +git commit -m "feat(web): persist idempotency evidence with business transactions" +``` + +### Task 40: Redis Concurrent Gate와 Replay Cache Adapter + +**Files:** +- Create: `modules/web/web-idempotency-redis/build.gradle.kts` +- Create: `modules/web/web-idempotency-redis/src/main/java/io/backend/skeleton/web/idempotency/redis/RedisIdempotencyGate.java` +- Create: `modules/web/web-idempotency-redis/src/main/java/io/backend/skeleton/web/idempotency/redis/RedisResponseReplayCache.java` +- Create: `modules/web/web-idempotency-redis/src/main/java/io/backend/skeleton/web/idempotency/redis/RedisIdempotencyKeyFactory.java` +- Modify: `settings.gradle.kts` +- Test: `modules/web/web-idempotency-redis/src/test/java/io/backend/skeleton/web/idempotency/redis/RedisIdempotencyGateIT.java` + +**Interfaces:** +- Consumes: Task 37 idempotency contract and Redis platform typed atomic operations +- Produces: fast concurrent claim exclusion and bounded replay cache that never claims to be DB commit evidence + +**Implementation requirements:** +- Redis adapter는 `APPLICATION_COMMITTED`의 유일한 source가 아니다. +- Redis failure 시 DB evidence를 조회하거나 operation profile의 fail-open/fail-closed 정책을 따른다. +- Replay cache TTL은 authoritative JPA record의 guarantee 기간을 넘지 않는다. + +- [ ] **Step 1: Write the failing test** + +```java +class RedisIdempotencyGateIT { + @org.junit.jupiter.api.Test + void redisGateCannotProduceCommitEvidence() { + var gate = RedisIdempotencyGate.fixture(); + var claim = gate.claim(IdempotencyFixtures.scope(), IdempotencyFixtures.fingerprint()); + + org.junit.jupiter.api.Assertions.assertTrue(claim.acquired()); + org.junit.jupiter.api.Assertions.assertFalse( + gate.authoritativeRecord(IdempotencyFixtures.scope()).isPresent() + ); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the expected failure** + +Run: `./gradlew :modules:web:web-idempotency-redis:test --tests '*RedisIdempotencyGateIT'` + +Expected: FAIL because the Redis integration module is absent. + +- [ ] **Step 3: Implement the minimum production contract** + +```java +public final class RedisIdempotencyGate { + private final RedisValueOperations values; + + public IdempotencyClaim claim( + IdempotencyScope scope, + RequestFingerprint fingerprint) { + var key = RedisIdempotencyKeyFactory.claimKey(scope); + var acquired = values.setIfAbsent( + key, + new RedisClaimValue(fingerprint.value()), + Expiration.ofMinutes(2) + ); + return acquired + ? IdempotencyClaim.acquired() + : IdempotencyClaim.inProgress(); + } + + public java.util.Optional authoritativeRecord( + IdempotencyScope scope) { + return java.util.Optional.empty(); + } +} +``` + +- [ ] **Step 4: Run the task test and its module contract suite** + +Run: `./gradlew :modules:web:web-idempotency-redis:test` + +Expected: PASS with no skipped contract assertions and no new warnings. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/web/web-idempotency-redis/build.gradle.kts' 'modules/web/web-idempotency-redis/src/main/java/io/backend/skeleton/web/idempotency/redis/RedisIdempotencyGate.java' 'modules/web/web-idempotency-redis/src/main/java/io/backend/skeleton/web/idempotency/redis/RedisResponseReplayCache.java' 'modules/web/web-idempotency-redis/src/main/java/io/backend/skeleton/web/idempotency/redis/RedisIdempotencyKeyFactory.java' 'settings.gradle.kts' 'modules/web/web-idempotency-redis/src/test/java/io/backend/skeleton/web/idempotency/redis/RedisIdempotencyGateIT.java' +git commit -m "feat(web): add Redis idempotency gate and replay cache" +``` + +### Task 41: MVC Idempotency Admission과 Response Replay + +**Files:** +- Create: `modules/web/web-mvc/src/main/java/io/backend/skeleton/web/mvc/idempotency/WebMvcIdempotencyInterceptor.java` +- Create: `modules/web/web-mvc/src/main/java/io/backend/skeleton/web/mvc/idempotency/WebMvcResponseSnapshotWriter.java` +- Create: `modules/web/web-mvc/src/main/java/io/backend/skeleton/web/mvc/idempotency/WebMvcIdempotencyArgumentResolver.java` +- Test: `modules/web/web-mvc/src/test/java/io/backend/skeleton/web/mvc/idempotency/WebMvcIdempotencyInterceptorTest.java` + +**Interfaces:** +- Consumes: Tasks 37–40 idempotency SPI, fingerprint, JPA authority, and Redis gate +- Produces: MVC route policy enforcing required key, concurrent conflict, fingerprint mismatch, and completed response replay + +**Implementation requirements:** +- `REQUIRED` mutation에서 key가 없으면 400 `IDEMPOTENCY_KEY_REQUIRED`다. +- 같은 key·다른 fingerprint는 422다. +- `PROCESSING`은 409, `COMPLETED_REPLAYABLE`은 original status/header/body를 반환한다. +- Response snapshot header는 allowlist만 저장한다. + +- [ ] **Step 1: Write the failing test** + +```java +class WebMvcIdempotencyInterceptorTest { + @org.junit.jupiter.api.Test + void replayReturnsOriginalCreatedResponse() throws Exception { + var fixture = WebMvcIdempotencyFixture.completedCreatedResponse(); + var response = fixture.invokeSameRequestAgain(); + + org.junit.jupiter.api.Assertions.assertEquals(201, response.status()); + org.junit.jupiter.api.Assertions.assertEquals( + "/api/v1/documents/d1", + response.header("Location") + ); + org.junit.jupiter.api.Assertions.assertEquals(1, fixture.businessInvocationCount()); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the expected failure** + +Run: `./gradlew :modules:web:web-mvc:test --tests '*WebMvcIdempotencyInterceptorTest'` + +Expected: FAIL because the MVC idempotency policy is not connected to request handling. + +- [ ] **Step 3: Implement the minimum production contract** + +```java +public final class WebMvcIdempotencyInterceptor + implements org.springframework.web.servlet.HandlerInterceptor { + + public boolean preHandle( + jakarta.servlet.http.HttpServletRequest request, + jakarta.servlet.http.HttpServletResponse response, + Object handler) { + var operation = operationResolver.require(handler); + if (operation.idempotency() == IdempotencyPolicy.NOT_SUPPORTED) { + return true; + } + var key = keyResolver.resolve(request, operation); + var decision = coordinator.beforeExecution(operation, key, request); + return decision.applyTo(response); + } +} +``` + +- [ ] **Step 4: Run the task test and its module contract suite** + +Run: `./gradlew :modules:web:web-mvc:test` + +Expected: PASS with no skipped contract assertions and no new warnings. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/web/web-mvc/src/main/java/io/backend/skeleton/web/mvc/idempotency/WebMvcIdempotencyInterceptor.java' 'modules/web/web-mvc/src/main/java/io/backend/skeleton/web/mvc/idempotency/WebMvcResponseSnapshotWriter.java' 'modules/web/web-mvc/src/main/java/io/backend/skeleton/web/mvc/idempotency/WebMvcIdempotencyArgumentResolver.java' 'modules/web/web-mvc/src/test/java/io/backend/skeleton/web/mvc/idempotency/WebMvcIdempotencyInterceptorTest.java' +git commit -m "feat(web): enforce MVC mutation idempotency and replay" +``` + +### Task 42: WebFlux Idempotency Admission과 Response Replay + +**Files:** +- Create: `modules/web/web-webflux/src/main/java/io/backend/skeleton/web/webflux/idempotency/WebFluxIdempotencyFilter.java` +- Create: `modules/web/web-webflux/src/main/java/io/backend/skeleton/web/webflux/idempotency/WebFluxResponseSnapshotWriter.java` +- Create: `modules/web/web-webflux/src/main/java/io/backend/skeleton/web/webflux/idempotency/ReactiveIdempotencyCoordinator.java` +- Test: `modules/web/web-webflux/src/test/java/io/backend/skeleton/web/webflux/idempotency/WebFluxIdempotencyFilterTest.java` + +**Interfaces:** +- Consumes: Tasks 37–40 idempotency contracts and Task 20 Reactor context +- Produces: non-blocking WebFlux claim, authority lookup, and completed-response replay + +**Implementation requirements:** +- JPA authoritative adapter 호출은 event-loop에서 직접 block하지 않는다. +- Reactive pipeline cancellation 시 claim을 잘못 `FAILED_RETRYABLE`로 전환하지 않는다. +- Partial response가 시작된 뒤 replay snapshot을 새로 만들지 않는다. + +- [ ] **Step 1: Write the failing test** + +```java +class WebFluxIdempotencyFilterTest { + @org.junit.jupiter.api.Test + void concurrentSameKeyReturnsConflictForSecondRequest() { + var fixture = WebFluxIdempotencyFixture.concurrentRequests(); + reactor.test.StepVerifier.create(fixture.secondResponse()) + .assertNext(response -> + org.junit.jupiter.api.Assertions.assertEquals(409, response.statusCode().value()) + ) + .verifyComplete(); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the expected failure** + +Run: `./gradlew :modules:web:web-webflux:test --tests '*WebFluxIdempotencyFilterTest'` + +Expected: FAIL because reactive idempotency coordination is absent. + +- [ ] **Step 3: Implement the minimum production contract** + +```java +public final class WebFluxIdempotencyFilter + implements org.springframework.web.server.WebFilter { + + public reactor.core.publisher.Mono filter( + org.springframework.web.server.ServerWebExchange exchange, + org.springframework.web.server.WebFilterChain chain) { + return operationResolver.resolve(exchange) + .flatMap(operation -> coordinator.beforeExecution(exchange, operation)) + .flatMap(decision -> decision.replay() + ? decision.writeReplay(exchange.getResponse()) + : chain.filter(exchange) + .transformDeferred(result -> decision.capture(result, exchange))); + } +} +``` + +- [ ] **Step 4: Run the task test and its module contract suite** + +Run: `./gradlew :modules:web:web-webflux:test` + +Expected: PASS with no skipped contract assertions and no new warnings. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/web/web-webflux/src/main/java/io/backend/skeleton/web/webflux/idempotency/WebFluxIdempotencyFilter.java' 'modules/web/web-webflux/src/main/java/io/backend/skeleton/web/webflux/idempotency/WebFluxResponseSnapshotWriter.java' 'modules/web/web-webflux/src/main/java/io/backend/skeleton/web/webflux/idempotency/ReactiveIdempotencyCoordinator.java' 'modules/web/web-webflux/src/test/java/io/backend/skeleton/web/webflux/idempotency/WebFluxIdempotencyFilterTest.java' +git commit -m "feat(web): enforce reactive mutation idempotency and replay" +``` + +### Task 43: Application Commit 후 HTTP Response 유실 Fault Test + +**Files:** +- Create: `modules/web/web-testkit-contract/src/main/java/io/backend/skeleton/web/testkit/fault/WebFaultPoint.java` +- Create: `modules/web/web-testkit-contract/src/main/java/io/backend/skeleton/web/testkit/fault/WebFaultInjector.java` +- Create: `modules/web/web-testkit-mvc/src/test/java/io/backend/skeleton/web/testkit/fault/CommitThenConnectionResetIT.java` +- Create: `modules/web/web-testkit-webflux/src/test/java/io/backend/skeleton/web/testkit/fault/ReactiveCommitThenConnectionResetIT.java` +- Test: `modules/web/web-testkit-contract/src/test/java/io/backend/skeleton/web/testkit/fault/IdempotencyResponseLossContractTest.java` + +**Interfaces:** +- Consumes: Tasks 36–42 evidence and idempotency integrations +- Produces: fault points around application start, transaction commit, response headers, partial body, and local write completion + +**Implementation requirements:** +- 핵심 fault point는 `AFTER_APPLICATION_COMMIT_BEFORE_RESPONSE_HEADERS`다. +- 첫 요청의 client는 IOException/connection reset을 관찰해도 business side effect는 정확히 1회다. +- 동일 key 재호출은 기존 committed outcome을 복원한다. + +- [ ] **Step 1: Write the failing test** + +```java +abstract class IdempotencyResponseLossContractTest { + protected abstract ResponseLossFixture fixture(); + + @org.junit.jupiter.api.Test + void committedMutationIsNotExecutedTwiceAfterResponseLoss() { + var scenario = fixture().at( + WebFaultPoint.AFTER_APPLICATION_COMMIT_BEFORE_RESPONSE_HEADERS + ); + + org.junit.jupiter.api.Assertions.assertThrows( + java.io.IOException.class, + scenario::firstCall + ); + + var replay = scenario.secondCallWithSameKey(); + org.junit.jupiter.api.Assertions.assertEquals(201, replay.status()); + org.junit.jupiter.api.Assertions.assertEquals(1, scenario.sideEffectCount()); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the expected failure** + +Run: `./gradlew :modules:web:web-testkit-mvc:test :modules:web:web-testkit-webflux:test --tests '*CommitThenConnectionResetIT'` + +Expected: FAIL because no response-loss fault point or committed replay path exists. + +- [ ] **Step 3: Implement the minimum production contract** + +```java +public enum WebFaultPoint { + BEFORE_APPLICATION_START, + AFTER_APPLICATION_START_BEFORE_TRANSACTION, + AFTER_APPLICATION_COMMIT_BEFORE_RESPONSE_HEADERS, + AFTER_RESPONSE_HEADERS_BEFORE_BODY, + AFTER_PARTIAL_BODY, + AFTER_LOCAL_WRITE_COMPLETION +} + +public interface WebFaultInjector { + void trigger(WebFaultPoint point) throws java.io.IOException; +} +``` + +- [ ] **Step 4: Run the task test and its module contract suite** + +Run: `./gradlew :modules:web:web-testkit-contract:test :modules:web:web-testkit-mvc:test :modules:web:web-testkit-webflux:test` + +Expected: PASS with no skipped contract assertions and no new warnings. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/web/web-testkit-contract/src/main/java/io/backend/skeleton/web/testkit/fault/WebFaultPoint.java' 'modules/web/web-testkit-contract/src/main/java/io/backend/skeleton/web/testkit/fault/WebFaultInjector.java' 'modules/web/web-testkit-mvc/src/test/java/io/backend/skeleton/web/testkit/fault/CommitThenConnectionResetIT.java' 'modules/web/web-testkit-webflux/src/test/java/io/backend/skeleton/web/testkit/fault/ReactiveCommitThenConnectionResetIT.java' 'modules/web/web-testkit-contract/src/test/java/io/backend/skeleton/web/testkit/fault/IdempotencyResponseLossContractTest.java' +git commit -m "test(web): prove idempotent recovery after committed response loss" +``` + +### Task 44: Durable Operation Core 계약 + +**Files:** +- Create: `modules/web/web-core-api/src/main/java/io/backend/skeleton/web/operationasync/OperationId.java` +- Create: `modules/web/web-core-api/src/main/java/io/backend/skeleton/web/operationasync/OperationStatus.java` +- Create: `modules/web/web-core-api/src/main/java/io/backend/skeleton/web/operationasync/OperationResource.java` +- Create: `modules/web/web-core-api/src/main/java/io/backend/skeleton/web/operationasync/OperationProgress.java` +- Create: `modules/web/web-core-api/src/main/java/io/backend/skeleton/web/operationasync/DurableOperationStore.java` +- Create: `modules/web/web-core-api/src/main/java/io/backend/skeleton/web/operationasync/OperationSubmission.java` +- Test: `modules/web/web-core-api/src/test/java/io/backend/skeleton/web/operationasync/OperationResourceTest.java` + +**Interfaces:** +- Consumes: Task 11 WebProblem and Task 3 request context identifiers +- Produces: durable PENDING/RUNNING/SUCCEEDED/FAILED/CANCELED/EXPIRED resource model + +**Implementation requirements:** +- 202 receipt와 operation state를 분리한다. +- `SUCCEEDED`는 resultLocation 또는 durable result reference를 가진다. +- `FAILED`는 sanitized WebProblem을 가진다. +- Process-local future나 `@Async` handle을 OperationResource로 사용하지 않는다. + +- [ ] **Step 1: Write the failing test** + +```java +class OperationResourceTest { + @org.junit.jupiter.api.Test + void succeededOperationRequiresResultLocation() { + org.junit.jupiter.api.Assertions.assertThrows( + IllegalArgumentException.class, + () -> OperationResource.succeeded( + new OperationId("op-1"), + java.time.Instant.now(), + java.util.Optional.empty() + ) + ); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the expected failure** + +Run: `./gradlew :modules:web:web-core-api:test --tests '*OperationResourceTest'` + +Expected: FAIL because durable operation types do not exist. + +- [ ] **Step 3: Implement the minimum production contract** + +```java +public enum OperationStatus { + PENDING, + RUNNING, + SUCCEEDED, + FAILED, + CANCELED, + EXPIRED +} + +public record OperationResource( + OperationId operationId, + OperationStatus status, + java.time.Instant createdAt, + java.util.Optional startedAt, + java.util.Optional completedAt, + java.util.Optional progress, + java.util.Optional resultLocation, + java.util.Optional problem, + java.util.Optional retryAfter, + java.time.Instant expiresAt) { +} +``` + +- [ ] **Step 4: Run the task test and its module contract suite** + +Run: `./gradlew :modules:web:web-core-api:test` + +Expected: PASS with no skipped contract assertions and no new warnings. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/web/web-core-api/src/main/java/io/backend/skeleton/web/operationasync/OperationId.java' 'modules/web/web-core-api/src/main/java/io/backend/skeleton/web/operationasync/OperationStatus.java' 'modules/web/web-core-api/src/main/java/io/backend/skeleton/web/operationasync/OperationResource.java' 'modules/web/web-core-api/src/main/java/io/backend/skeleton/web/operationasync/OperationProgress.java' 'modules/web/web-core-api/src/main/java/io/backend/skeleton/web/operationasync/DurableOperationStore.java' 'modules/web/web-core-api/src/main/java/io/backend/skeleton/web/operationasync/OperationSubmission.java' 'modules/web/web-core-api/src/test/java/io/backend/skeleton/web/operationasync/OperationResourceTest.java' +git commit -m "feat(web): define durable operation resources" +``` + +### Task 45: JPA Durable Operation Store·Lease·Cancel + +**Files:** +- Create: `modules/web/web-operation-jpa/build.gradle.kts` +- Create: `modules/web/web-operation-jpa/src/main/java/io/backend/skeleton/web/operationasync/jpa/JpaOperationEntity.java` +- Create: `modules/web/web-operation-jpa/src/main/java/io/backend/skeleton/web/operationasync/jpa/JpaDurableOperationStore.java` +- Create: `modules/web/web-operation-jpa/src/main/java/io/backend/skeleton/web/operationasync/jpa/OperationLease.java` +- Create: `modules/web/web-operation-jpa/src/main/java/io/backend/skeleton/web/operationasync/jpa/OperationWorker.java` +- Create: `modules/web/web-operation-jpa/src/main/resources/db/migration/web/V002__web_operation.sql` +- Modify: `settings.gradle.kts` +- Test: `modules/web/web-operation-jpa/src/test/java/io/backend/skeleton/web/operationasync/jpa/JpaDurableOperationStoreIT.java` + +**Interfaces:** +- Consumes: Task 44 durable operation SPI and JPA platform transaction/locking facilities +- Produces: durable operation rows, SKIP LOCKED lease claims, idempotent cancel, expiration, and restart recovery + +**Implementation requirements:** +- Worker claim은 lease owner와 lease expiry를 가진다. +- 프로세스 종료 후 만료 lease를 다른 worker가 복구한다. +- 취소는 `PENDING/RUNNING`에서만 허용하며 이미 terminal인 상태를 되돌리지 않는다. + +- [ ] **Step 1: Write the failing test** + +```java +@org.springframework.boot.test.context.SpringBootTest +class JpaDurableOperationStoreIT { + @org.junit.jupiter.api.Test + void expiredLeaseCanBeReclaimedAfterWorkerCrash() { + var operation = fixture.createPending(); + fixture.claimAndCrash(operation); + fixture.advanceClock(java.time.Duration.ofMinutes(2)); + + var reclaimed = fixture.claimNext(); + org.junit.jupiter.api.Assertions.assertEquals(operation, reclaimed.operationId()); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the expected failure** + +Run: `./gradlew :modules:web:web-operation-jpa:test --tests '*JpaDurableOperationStoreIT'` + +Expected: FAIL because the durable operation schema and lease implementation are absent. + +- [ ] **Step 3: Implement the minimum production contract** + +```sql +CREATE TABLE web_operation ( + operation_id varchar(64) PRIMARY KEY, + tenant_hash varchar(128) NOT NULL, + actor_hash varchar(128) NOT NULL, + operation_name varchar(128) NOT NULL, + status varchar(32) NOT NULL, + progress jsonb, + result_location text, + problem jsonb, + lease_owner varchar(128), + lease_expires_at timestamptz, + created_at timestamptz NOT NULL, + started_at timestamptz, + completed_at timestamptz, + expires_at timestamptz NOT NULL, + version bigint NOT NULL +); + +CREATE INDEX ix_web_operation_claim +ON web_operation(status, lease_expires_at, created_at); +``` + +- [ ] **Step 4: Run the task test and its module contract suite** + +Run: `./gradlew :modules:web:web-operation-jpa:test` + +Expected: PASS with no skipped contract assertions and no new warnings. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/web/web-operation-jpa/build.gradle.kts' 'modules/web/web-operation-jpa/src/main/java/io/backend/skeleton/web/operationasync/jpa/JpaOperationEntity.java' 'modules/web/web-operation-jpa/src/main/java/io/backend/skeleton/web/operationasync/jpa/JpaDurableOperationStore.java' 'modules/web/web-operation-jpa/src/main/java/io/backend/skeleton/web/operationasync/jpa/OperationLease.java' 'modules/web/web-operation-jpa/src/main/java/io/backend/skeleton/web/operationasync/jpa/OperationWorker.java' 'modules/web/web-operation-jpa/src/main/resources/db/migration/web/V002__web_operation.sql' 'settings.gradle.kts' 'modules/web/web-operation-jpa/src/test/java/io/backend/skeleton/web/operationasync/jpa/JpaDurableOperationStoreIT.java' +git commit -m "feat(web): persist and recover durable operations" +``` + +### Task 46: Messaging·Outbox Durable Operation Bridge + +**Files:** +- Create: `modules/web/web-operation-messaging/build.gradle.kts` +- Create: `modules/web/web-operation-messaging/src/main/java/io/backend/skeleton/web/operationasync/messaging/OperationCommand.java` +- Create: `modules/web/web-operation-messaging/src/main/java/io/backend/skeleton/web/operationasync/messaging/OperationCommandPublisher.java` +- Create: `modules/web/web-operation-messaging/src/main/java/io/backend/skeleton/web/operationasync/messaging/OutboxBackedOperationSubmission.java` +- Create: `modules/web/web-operation-messaging/src/main/java/io/backend/skeleton/web/operationasync/messaging/OperationResultProjector.java` +- Modify: `settings.gradle.kts` +- Test: `modules/web/web-operation-messaging/src/test/java/io/backend/skeleton/web/operationasync/messaging/OperationOutboxAtomicityIT.java` + +**Interfaces:** +- Consumes: Task 44 operation core, Task 45 JPA store, Messaging platform publisher and transactional outbox +- Produces: business DB transaction plus operation/outbox acceptance before HTTP 202 + +**Implementation requirements:** +- 202는 durable operation row 또는 business transaction+outbox commit 이후에만 반환한다. +- Broker publish가 늦거나 중복돼도 하나의 operationId를 유지한다. +- Messaging ACK·Retry·DLQ는 messaging module이 소유한다. + +- [ ] **Step 1: Write the failing test** + +```java +@org.springframework.boot.test.context.SpringBootTest +class OperationOutboxAtomicityIT { + @org.junit.jupiter.api.Test + void rollbackDoesNotLeaveAcceptedOperationOrOutboxMessage() { + var result = fixture.submitAndRollback(); + org.junit.jupiter.api.Assertions.assertFalse(result.operationExists()); + org.junit.jupiter.api.Assertions.assertFalse(result.outboxExists()); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the expected failure** + +Run: `./gradlew :modules:web:web-operation-messaging:test --tests '*OperationOutboxAtomicityIT'` + +Expected: FAIL because no transactional operation-to-outbox bridge exists. + +- [ ] **Step 3: Implement the minimum production contract** + +```java +public final class OutboxBackedOperationSubmission { + @org.springframework.transaction.annotation.Transactional + public OperationSubmission submit(OperationCommand command) { + var operation = operationStore.createPending(command); + outboxWriter.append(OperationCommandEnvelope.from(operation, command)); + return new OperationSubmission(operation.operationId(), operation.createdAt()); + } +} +``` + +- [ ] **Step 4: Run the task test and its module contract suite** + +Run: `./gradlew :modules:web:web-operation-messaging:test` + +Expected: PASS with no skipped contract assertions and no new warnings. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/web/web-operation-messaging/build.gradle.kts' 'modules/web/web-operation-messaging/src/main/java/io/backend/skeleton/web/operationasync/messaging/OperationCommand.java' 'modules/web/web-operation-messaging/src/main/java/io/backend/skeleton/web/operationasync/messaging/OperationCommandPublisher.java' 'modules/web/web-operation-messaging/src/main/java/io/backend/skeleton/web/operationasync/messaging/OutboxBackedOperationSubmission.java' 'modules/web/web-operation-messaging/src/main/java/io/backend/skeleton/web/operationasync/messaging/OperationResultProjector.java' 'settings.gradle.kts' 'modules/web/web-operation-messaging/src/test/java/io/backend/skeleton/web/operationasync/messaging/OperationOutboxAtomicityIT.java' +git commit -m "feat(web): accept durable operations through transactional outbox" +``` + +### Task 47: Operation Resource HTTP API + +**Files:** +- Create: `modules/web/web-mvc/src/main/java/io/backend/skeleton/web/mvc/operation/OperationHttpController.java` +- Create: `modules/web/web-webflux/src/main/java/io/backend/skeleton/web/webflux/operation/ReactiveOperationHttpController.java` +- Create: `modules/web/web-contract/src/main/java/io/backend/skeleton/web/operation/OperationResponse.java` +- Create: `modules/web/web-contract/src/main/java/io/backend/skeleton/web/operation/OperationResponseMapper.java` +- Test: `modules/web/web-testkit-contract/src/test/java/io/backend/skeleton/web/testkit/operation/OperationHttpContractTest.java` + +**Interfaces:** +- Consumes: Tasks 44–46 durable operation model, store, and submission +- Produces: POST 202 Location, GET status, cancel, retry-after, resultLocation, failure Problem, and expiration contracts + +**Implementation requirements:** +- `POST` receipt는 202와 operation `Location`을 반환한다. +- `GET`은 PENDING/RUNNING/SUCCEEDED/FAILED/CANCELED/EXPIRED를 정확히 표현한다. +- Operation 접근은 actor/tenant/object authorization을 수행한다. + +- [ ] **Step 1: Write the failing test** + +```java +abstract class OperationHttpContractTest { + protected abstract OperationHttpFixture fixture(); + + @org.junit.jupiter.api.Test + void acceptedSubmissionHasOperationLocation() { + var response = fixture().submit(); + org.junit.jupiter.api.Assertions.assertEquals(202, response.status()); + org.junit.jupiter.api.Assertions.assertTrue( + response.header("Location").startsWith("/api/v1/operations/") + ); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the expected failure** + +Run: `./gradlew :modules:web:web-testkit-contract:test --tests '*OperationHttpContractTest'` + +Expected: FAIL because the operation HTTP adapters are missing. + +- [ ] **Step 3: Implement the minimum production contract** + +```java +@RestController +@RequestMapping("/api/v1/operations") +final class OperationHttpController { + @GetMapping("/{operationId}") + ResponseEntity get( + @PathVariable String operationId, + WebRequestContext context) { + var resource = query.requireAuthorized(new OperationId(operationId), context); + return ResponseEntity.ok(OperationResponseMapper.from(resource)); + } + + @DeleteMapping("/{operationId}") + ResponseEntity cancel( + @PathVariable String operationId, + WebRequestContext context) { + command.cancel(new OperationId(operationId), context.actor(), context.tenant()); + return ResponseEntity.noContent().build(); + } +} +``` + +- [ ] **Step 4: Run the task test and its module contract suite** + +Run: `./gradlew :modules:web:web-testkit-contract:test :modules:web:web-mvc:test :modules:web:web-webflux:test` + +Expected: PASS with no skipped contract assertions and no new warnings. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/web/web-mvc/src/main/java/io/backend/skeleton/web/mvc/operation/OperationHttpController.java' 'modules/web/web-webflux/src/main/java/io/backend/skeleton/web/webflux/operation/ReactiveOperationHttpController.java' 'modules/web/web-contract/src/main/java/io/backend/skeleton/web/operation/OperationResponse.java' 'modules/web/web-contract/src/main/java/io/backend/skeleton/web/operation/OperationResponseMapper.java' 'modules/web/web-testkit-contract/src/test/java/io/backend/skeleton/web/testkit/operation/OperationHttpContractTest.java' +git commit -m "feat(web): expose durable operation resources" +``` + +### Task 48: HTTP Cache·ETag·Vary Policy + +**Files:** +- Create: `modules/web/web-contract/src/main/java/io/backend/skeleton/web/cache/WebCachePolicy.java` +- Create: `modules/web/web-contract/src/main/java/io/backend/skeleton/web/cache/WebCachePolicyCatalog.java` +- Create: `modules/web/web-contract/src/main/java/io/backend/skeleton/web/cache/WebCacheHeaders.java` +- Create: `modules/web/web-contract/src/main/java/io/backend/skeleton/web/cache/WebVaryPolicy.java` +- Test: `modules/web/web-contract/src/test/java/io/backend/skeleton/web/cache/WebCachePolicyTest.java` + +**Interfaces:** +- Consumes: Tasks 10 and 34 status/header and ETag semantics +- Produces: explicit private/no-store, public immutable, public revalidated, and browser-private cache profiles + +**Implementation requirements:** +- `no-cache`와 `no-store`를 구분한다. +- Authorization이 있는 응답을 shared cache에 저장하려면 명시 profile이 필요하다. +- `Vary`는 실제 representation을 바꾸는 bounded request header만 포함한다. + +- [ ] **Step 1: Write the failing test** + +```java +class WebCachePolicyTest { + @org.junit.jupiter.api.Test + void sensitiveResponseIsPrivateNoStore() { + var headers = WebCachePolicyCatalog.standard() + .require("sensitive") + .headers(); + + org.junit.jupiter.api.Assertions.assertEquals( + "private, no-store", + headers.cacheControl() + ); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the expected failure** + +Run: `./gradlew :modules:web:web-contract:test --tests '*WebCachePolicyTest'` + +Expected: FAIL because cache policy profiles are missing. + +- [ ] **Step 3: Implement the minimum production contract** + +```java +public record WebCachePolicy( + String name, + String cacheControl, + java.util.Set varyHeaders, + boolean sharedCacheAllowed) { +} + +public final class WebCachePolicyCatalog { + public static WebCachePolicy sensitive() { + return new WebCachePolicy( + "sensitive", + "private, no-store", + java.util.Set.of(), + false + ); + } +} +``` + +- [ ] **Step 4: Run the task test and its module contract suite** + +Run: `./gradlew :modules:web:web-contract:test` + +Expected: PASS with no skipped contract assertions and no new warnings. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/web/web-contract/src/main/java/io/backend/skeleton/web/cache/WebCachePolicy.java' 'modules/web/web-contract/src/main/java/io/backend/skeleton/web/cache/WebCachePolicyCatalog.java' 'modules/web/web-contract/src/main/java/io/backend/skeleton/web/cache/WebCacheHeaders.java' 'modules/web/web-contract/src/main/java/io/backend/skeleton/web/cache/WebVaryPolicy.java' 'modules/web/web-contract/src/test/java/io/backend/skeleton/web/cache/WebCachePolicyTest.java' +git commit -m "feat(web): define explicit HTTP cache policies" +``` + +### Task 49: Request·Response Budget Enforcement + +**Files:** +- Create: `modules/web/web-mvc/src/main/java/io/backend/skeleton/web/mvc/budget/WebMvcBudgetFilter.java` +- Create: `modules/web/web-mvc/src/main/java/io/backend/skeleton/web/mvc/budget/BoundedHttpServletResponse.java` +- Create: `modules/web/web-webflux/src/main/java/io/backend/skeleton/web/webflux/budget/WebFluxBudgetFilter.java` +- Create: `modules/web/web-webflux/src/main/java/io/backend/skeleton/web/webflux/budget/BoundedServerHttpResponse.java` +- Create: `modules/web/web-core-api/src/main/java/io/backend/skeleton/web/budget/WebBudgetExceededException.java` +- Test: `modules/web/web-testkit-contract/src/test/java/io/backend/skeleton/web/testkit/budget/WebBudgetContractTest.java` + +**Interfaces:** +- Consumes: Task 6 budgets, Task 13 error mapping, Tasks 16 and 20 stack contexts +- Produces: 413/400/503 enforcement for URI, headers, query count, body, JSON depth, arrays, multipart, execution, and response bytes + +**Implementation requirements:** +- Nginx와 application hard limits의 차이를 support manifest에 기록한다. +- Request body를 전체 메모리에 materialize해서 size를 재지 않는다. +- Response가 commit되기 전에 size 초과를 알 수 있으면 Problem으로 거부하고, stream 중 초과는 partial response evidence로 종료한다. + +- [ ] **Step 1: Write the failing test** + +```java +abstract class WebBudgetContractTest { + protected abstract WebBudgetFixture fixture(); + + @org.junit.jupiter.api.Test + void bodyAboveLimitIsRejectedWith413() { + var response = fixture().postBodyOfSize(1_048_577); + org.junit.jupiter.api.Assertions.assertEquals(413, response.status()); + org.junit.jupiter.api.Assertions.assertEquals( + ProblemCode.REQUEST_TOO_LARGE, + response.problemCode() + ); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the expected failure** + +Run: `./gradlew :modules:web:web-testkit-contract:test --tests '*WebBudgetContractTest'` + +Expected: FAIL because stack-specific budget enforcement is missing. + +- [ ] **Step 3: Implement the minimum production contract** + +```java +public final class WebBudgetExceededException extends RuntimeException { + private final BudgetDimension dimension; + + public WebBudgetExceededException(BudgetDimension dimension) { + super("web request budget exceeded"); + this.dimension = dimension; + } + + public BudgetDimension dimension() { + return dimension; + } +} +``` + +- [ ] **Step 4: Run the task test and its module contract suite** + +Run: `./gradlew :modules:web:web-testkit-contract:test :modules:web:web-mvc:test :modules:web:web-webflux:test` + +Expected: PASS with no skipped contract assertions and no new warnings. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/web/web-mvc/src/main/java/io/backend/skeleton/web/mvc/budget/WebMvcBudgetFilter.java' 'modules/web/web-mvc/src/main/java/io/backend/skeleton/web/mvc/budget/BoundedHttpServletResponse.java' 'modules/web/web-webflux/src/main/java/io/backend/skeleton/web/webflux/budget/WebFluxBudgetFilter.java' 'modules/web/web-webflux/src/main/java/io/backend/skeleton/web/webflux/budget/BoundedServerHttpResponse.java' 'modules/web/web-core-api/src/main/java/io/backend/skeleton/web/budget/WebBudgetExceededException.java' 'modules/web/web-testkit-contract/src/test/java/io/backend/skeleton/web/testkit/budget/WebBudgetContractTest.java' +git commit -m "feat(web): enforce inbound and outbound resource budgets" +``` + +### Task 50: Rate Limit Capability와 HTTP 429 Mapping + +**Files:** +- Create: `modules/web/web-core-api/src/main/java/io/backend/skeleton/web/ratelimit/WebRateLimiter.java` +- Create: `modules/web/web-core-api/src/main/java/io/backend/skeleton/web/ratelimit/RateLimitDecision.java` +- Create: `modules/web/web-core-api/src/main/java/io/backend/skeleton/web/ratelimit/RateLimitProfileName.java` +- Create: `modules/web/web-mvc/src/main/java/io/backend/skeleton/web/mvc/ratelimit/WebMvcRateLimitInterceptor.java` +- Create: `modules/web/web-webflux/src/main/java/io/backend/skeleton/web/webflux/ratelimit/WebFluxRateLimitFilter.java` +- Test: `modules/web/web-testkit-contract/src/test/java/io/backend/skeleton/web/testkit/ratelimit/WebRateLimitHttpContractTest.java` + +**Interfaces:** +- Consumes: Task 3 request context, Task 5 operation profile, Redis rate-limit capability +- Produces: actor/IP/tenant/route quota decisions mapped to 429, Retry-After, and RATE_LIMITED Problem + +**Implementation requirements:** +- Rate limit은 일정 기간 quota이며 현재 capacity admission과 분리한다. +- 새 draft RateLimit headers는 Stable에서 반환하지 않는다. +- Redis failure의 fail-open/fail-closed는 operation profile에 명시한다. + +- [ ] **Step 1: Write the failing test** + +```java +abstract class WebRateLimitHttpContractTest { + protected abstract RateLimitHttpFixture fixture(); + + @org.junit.jupiter.api.Test + void quotaExhaustionReturns429AndRetryAfter() { + var response = fixture().exhaustAndCall(); + org.junit.jupiter.api.Assertions.assertEquals(429, response.status()); + org.junit.jupiter.api.Assertions.assertNotNull(response.header("Retry-After")); + org.junit.jupiter.api.Assertions.assertEquals( + ProblemCode.RATE_LIMITED, + response.problemCode() + ); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the expected failure** + +Run: `./gradlew :modules:web:web-testkit-contract:test --tests '*WebRateLimitHttpContractTest'` + +Expected: FAIL because rate limit decisions are not mapped into HTTP semantics. + +- [ ] **Step 3: Implement the minimum production contract** + +```java +public interface WebRateLimiter { + RateLimitDecision evaluate( + WebRequestContext context, + RateLimitProfileName profile + ); +} + +public record RateLimitDecision( + boolean allowed, + long limit, + long remaining, + java.time.Instant resetAt, + java.util.Optional retryAfter) { +} +``` + +- [ ] **Step 4: Run the task test and its module contract suite** + +Run: `./gradlew :modules:web:web-testkit-contract:test :modules:web:web-mvc:test :modules:web:web-webflux:test` + +Expected: PASS with no skipped contract assertions and no new warnings. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/web/web-core-api/src/main/java/io/backend/skeleton/web/ratelimit/WebRateLimiter.java' 'modules/web/web-core-api/src/main/java/io/backend/skeleton/web/ratelimit/RateLimitDecision.java' 'modules/web/web-core-api/src/main/java/io/backend/skeleton/web/ratelimit/RateLimitProfileName.java' 'modules/web/web-mvc/src/main/java/io/backend/skeleton/web/mvc/ratelimit/WebMvcRateLimitInterceptor.java' 'modules/web/web-webflux/src/main/java/io/backend/skeleton/web/webflux/ratelimit/WebFluxRateLimitFilter.java' 'modules/web/web-testkit-contract/src/test/java/io/backend/skeleton/web/testkit/ratelimit/WebRateLimitHttpContractTest.java' +git commit -m "feat(web): map distributed rate limits to HTTP 429" +``` + +### Task 51: Admission Control과 HTTP 503 Mapping + +**Files:** +- Create: `modules/web/web-core-api/src/main/java/io/backend/skeleton/web/admission/WebAdmissionController.java` +- Create: `modules/web/web-core-api/src/main/java/io/backend/skeleton/web/admission/AdmissionDecision.java` +- Create: `modules/web/web-core-api/src/main/java/io/backend/skeleton/web/admission/AdmissionProfileName.java` +- Create: `modules/web/web-core-api/src/main/java/io/backend/skeleton/web/admission/SemaphoreAdmissionController.java` +- Create: `modules/web/web-mvc/src/main/java/io/backend/skeleton/web/mvc/admission/WebMvcAdmissionInterceptor.java` +- Create: `modules/web/web-webflux/src/main/java/io/backend/skeleton/web/webflux/admission/WebFluxAdmissionFilter.java` +- Test: `modules/web/web-testkit-contract/src/test/java/io/backend/skeleton/web/testkit/admission/WebAdmissionHttpContractTest.java` + +**Interfaces:** +- Consumes: Task 3 request context, Task 5 operation profile, Task 50 distinct rate-limit semantics +- Produces: bounded concurrency, small bounded queue, queue timeout, and 503 ADMISSION_REJECTED mapping + +**Implementation requirements:** +- Global write와 expensive query에 서로 다른 profile을 사용한다. +- 무한 queue를 사용하지 않는다. +- Capacity rejection은 503이며 actor quota 429와 구분한다. + +- [ ] **Step 1: Write the failing test** + +```java +abstract class WebAdmissionHttpContractTest { + protected abstract AdmissionHttpFixture fixture(); + + @org.junit.jupiter.api.Test + void saturatedWriteAdmissionReturns503() { + var response = fixture().saturateAndCallWrite(); + org.junit.jupiter.api.Assertions.assertEquals(503, response.status()); + org.junit.jupiter.api.Assertions.assertEquals( + ProblemCode.ADMISSION_REJECTED, + response.problemCode() + ); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the expected failure** + +Run: `./gradlew :modules:web:web-testkit-contract:test --tests '*WebAdmissionHttpContractTest'` + +Expected: FAIL because admission control is not implemented. + +- [ ] **Step 3: Implement the minimum production contract** + +```java +public interface WebAdmissionController { + AdmissionDecision admit( + WebRequestContext context, + AdmissionProfileName profile + ); +} + +public record AdmissionDecision( + boolean admitted, + java.util.Optional permit, + java.util.Optional retryAfter) { +} +``` + +- [ ] **Step 4: Run the task test and its module contract suite** + +Run: `./gradlew :modules:web:web-testkit-contract:test :modules:web:web-mvc:test :modules:web:web-webflux:test` + +Expected: PASS with no skipped contract assertions and no new warnings. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/web/web-core-api/src/main/java/io/backend/skeleton/web/admission/WebAdmissionController.java' 'modules/web/web-core-api/src/main/java/io/backend/skeleton/web/admission/AdmissionDecision.java' 'modules/web/web-core-api/src/main/java/io/backend/skeleton/web/admission/AdmissionProfileName.java' 'modules/web/web-core-api/src/main/java/io/backend/skeleton/web/admission/SemaphoreAdmissionController.java' 'modules/web/web-mvc/src/main/java/io/backend/skeleton/web/mvc/admission/WebMvcAdmissionInterceptor.java' 'modules/web/web-webflux/src/main/java/io/backend/skeleton/web/webflux/admission/WebFluxAdmissionFilter.java' 'modules/web/web-testkit-contract/src/test/java/io/backend/skeleton/web/testkit/admission/WebAdmissionHttpContractTest.java' +git commit -m "feat(web): enforce bounded request admission" +``` + +### Task 52: CORS·CSRF Credential Profile + +**Files:** +- Create: `modules/web/web-security-integration/src/main/java/io/backend/skeleton/web/security/CorsProfile.java` +- Create: `modules/web/web-security-integration/src/main/java/io/backend/skeleton/web/security/CsrfProfile.java` +- Create: `modules/web/web-security-integration/src/main/java/io/backend/skeleton/web/security/WebCredentialMode.java` +- Create: `modules/web/web-security-integration/src/main/java/io/backend/skeleton/web/security/WebCorsPolicyValidator.java` +- Create: `modules/web/web-security-integration/src/main/java/io/backend/skeleton/web/security/WebCsrfPolicyResolver.java` +- Test: `modules/web/web-security-integration/src/test/java/io/backend/skeleton/web/security/WebCorsCsrfPolicyTest.java` + +**Interfaces:** +- Consumes: Task 23 authenticated context and MVC/WebFlux Spring Security integration +- Produces: exact-origin CORS profiles and credential-mode-driven CSRF requirements + +**Implementation requirements:** +- CORS는 Security authentication보다 먼저 preflight를 처리한다. +- `allowedOrigins=*`와 credentials=true 조합을 거부한다. +- Session/BFF cookie mutation은 CSRF 보호가 필수다. +- Authorization-header-only non-browser profile은 명시 threat model에서만 CSRF를 비활성화한다. + +- [ ] **Step 1: Write the failing test** + +```java +class WebCorsCsrfPolicyTest { + @org.junit.jupiter.api.Test + void rejectsWildcardOriginWithCredentials() { + var profile = new CorsProfile( + java.util.Set.of("*"), + java.util.Set.of("GET", "POST"), + java.util.Set.of("Content-Type"), + true + ); + org.junit.jupiter.api.Assertions.assertThrows( + IllegalStateException.class, + () -> new WebCorsPolicyValidator().validate(profile) + ); + } + + @org.junit.jupiter.api.Test + void sessionCookieRequiresCsrf() { + org.junit.jupiter.api.Assertions.assertTrue( + new WebCsrfPolicyResolver().resolve(WebCredentialMode.SESSION_COOKIE).enabled() + ); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the expected failure** + +Run: `./gradlew :modules:web:web-security-integration:test --tests '*WebCorsCsrfPolicyTest'` + +Expected: FAIL because CORS and CSRF profiles are not formalized. + +- [ ] **Step 3: Implement the minimum production contract** + +```java +public final class WebCorsPolicyValidator { + public void validate(CorsProfile profile) { + if (profile.allowCredentials() && profile.allowedOrigins().contains("*")) { + throw new IllegalStateException( + "credentialed CORS requires exact origin allowlist" + ); + } + } +} + +public final class WebCsrfPolicyResolver { + public CsrfProfile resolve(WebCredentialMode mode) { + return switch (mode) { + case SESSION_COOKIE, BFF_COOKIE, COOKIE_AND_BEARER -> + CsrfProfile.required(); + case AUTHORIZATION_HEADER_ONLY, SERVICE_TO_SERVICE -> + CsrfProfile.explicitlyReviewed(); + }; + } +} +``` + +- [ ] **Step 4: Run the task test and its module contract suite** + +Run: `./gradlew :modules:web:web-security-integration:test` + +Expected: PASS with no skipped contract assertions and no new warnings. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/web/web-security-integration/src/main/java/io/backend/skeleton/web/security/CorsProfile.java' 'modules/web/web-security-integration/src/main/java/io/backend/skeleton/web/security/CsrfProfile.java' 'modules/web/web-security-integration/src/main/java/io/backend/skeleton/web/security/WebCredentialMode.java' 'modules/web/web-security-integration/src/main/java/io/backend/skeleton/web/security/WebCorsPolicyValidator.java' 'modules/web/web-security-integration/src/main/java/io/backend/skeleton/web/security/WebCsrfPolicyResolver.java' 'modules/web/web-security-integration/src/test/java/io/backend/skeleton/web/security/WebCorsCsrfPolicyTest.java' +git commit -m "feat(web): add credential-aware CORS and CSRF profiles" +``` + +### Task 53: Filter·Interceptor·Advice 의미 순서와 Async Redispatch 검증 + +**Files:** +- Create: `modules/web/web-testkit-contract/src/main/java/io/backend/skeleton/web/testkit/order/WebPipelineStage.java` +- Create: `modules/web/web-testkit-contract/src/main/java/io/backend/skeleton/web/testkit/order/WebPipelineRecorder.java` +- Create: `modules/web/web-testkit-mvc/src/test/java/io/backend/skeleton/web/testkit/order/MvcPipelineOrderIT.java` +- Create: `modules/web/web-testkit-webflux/src/test/java/io/backend/skeleton/web/testkit/order/WebFluxPipelineOrderIT.java` +- Test: `modules/web/web-testkit-contract/src/test/java/io/backend/skeleton/web/testkit/order/WebPipelineOrderContractTest.java` + +**Interfaces:** +- Consumes: Tasks 16, 20, 23–25, 41–42, 49–52 stack policies +- Produces: verified semantic order from forwarded normalization through response/error observation, including MVC ASYNC redispatch + +**Implementation requirements:** +- Global edge policy는 route selection 전, route-specific policy는 handler mapping 후 실행한다. +- Request body는 filter에서 선점하지 않는다. +- Metric/access log completion은 logical request당 한 번만 기록한다. + +- [ ] **Step 1: Write the failing test** + +```java +abstract class WebPipelineOrderContractTest { + protected abstract java.util.List observedStages(); + + @org.junit.jupiter.api.Test + void stagesFollowSemanticOrder() { + org.junit.jupiter.api.Assertions.assertEquals( + java.util.List.of( + WebPipelineStage.FORWARDED_NORMALIZATION, + WebPipelineStage.CORS, + WebPipelineStage.TRACE, + WebPipelineStage.AUTHENTICATION, + WebPipelineStage.GLOBAL_ADMISSION, + WebPipelineStage.ROUTE_SELECTED, + WebPipelineStage.ROUTE_POLICY, + WebPipelineStage.BINDING_VALIDATION, + WebPipelineStage.CONTROLLER, + WebPipelineStage.RESPONSE_OR_PROBLEM, + WebPipelineStage.OBSERVATION + ), + observedStages() + ); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the expected failure** + +Run: `./gradlew :modules:web:web-testkit-mvc:test :modules:web:web-testkit-webflux:test --tests '*PipelineOrderIT'` + +Expected: FAIL because the actual stack order is not asserted. + +- [ ] **Step 3: Implement the minimum production contract** + +```java +public enum WebPipelineStage { + FORWARDED_NORMALIZATION, + CORS, + TRACE, + AUTHENTICATION, + GLOBAL_ADMISSION, + ROUTE_SELECTED, + ROUTE_POLICY, + BINDING_VALIDATION, + CONTROLLER, + RESPONSE_OR_PROBLEM, + OBSERVATION +} + +public final class WebPipelineRecorder { + private final java.util.List stages = + new java.util.concurrent.CopyOnWriteArrayList<>(); + + public void record(WebPipelineStage stage) { + stages.add(stage); + } + + public java.util.List snapshot() { + return java.util.List.copyOf(stages); + } +} +``` + +- [ ] **Step 4: Run the task test and its module contract suite** + +Run: `./gradlew :modules:web:web-testkit-contract:test :modules:web:web-testkit-mvc:test :modules:web:web-testkit-webflux:test` + +Expected: PASS with no skipped contract assertions and no new warnings. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/web/web-testkit-contract/src/main/java/io/backend/skeleton/web/testkit/order/WebPipelineStage.java' 'modules/web/web-testkit-contract/src/main/java/io/backend/skeleton/web/testkit/order/WebPipelineRecorder.java' 'modules/web/web-testkit-mvc/src/test/java/io/backend/skeleton/web/testkit/order/MvcPipelineOrderIT.java' 'modules/web/web-testkit-webflux/src/test/java/io/backend/skeleton/web/testkit/order/WebFluxPipelineOrderIT.java' 'modules/web/web-testkit-contract/src/test/java/io/backend/skeleton/web/testkit/order/WebPipelineOrderContractTest.java' +git commit -m "test(web): lock filter and handler execution order" +``` + +### Task 54: Metric·Trace·Access Log·Audit 계약 + +**Files:** +- Create: `modules/web/web-observability/src/main/java/io/backend/skeleton/web/observability/WebObservationConvention.java` +- Create: `modules/web/web-observability/src/main/java/io/backend/skeleton/web/observability/WebMetricCardinalityPolicy.java` +- Create: `modules/web/web-observability/src/main/java/io/backend/skeleton/web/observability/WebAccessLogEvent.java` +- Create: `modules/web/web-observability/src/main/java/io/backend/skeleton/web/observability/WebAccessLogger.java` +- Create: `modules/web/web-observability/src/main/java/io/backend/skeleton/web/observability/WebAuditEvent.java` +- Create: `modules/web/web-observability/src/main/java/io/backend/skeleton/web/observability/WebAuditPublisher.java` +- Test: `modules/web/web-observability/src/test/java/io/backend/skeleton/web/observability/WebObservabilityPolicyTest.java` + +**Interfaces:** +- Consumes: Tasks 2–5 identifiers/evidence/operations, Task 28 route inventory, Problem catalog +- Produces: low-cardinality observations and redacted structured access/audit events + +**Implementation requirements:** +- Metric tag에는 routeTemplate, method, status, operationName, problemCode, profile만 허용한다. +- 전체 URL, query string, userId, tenantId 원문, resourceId, key, token, cookie, body를 tag에 넣지 않는다. +- Admin force-delete, redrive, permission change, sunset change, idempotency override는 audit event로 분리한다. + +- [ ] **Step 1: Write the failing test** + +```java +class WebObservabilityPolicyTest { + @org.junit.jupiter.api.Test + void rejectsHighCardinalityAndSecrets() { + var policy = WebMetricCardinalityPolicy.standard(); + + org.junit.jupiter.api.Assertions.assertFalse(policy.allowed("userId")); + org.junit.jupiter.api.Assertions.assertFalse(policy.allowed("http.url")); + org.junit.jupiter.api.Assertions.assertFalse(policy.allowed("idempotencyKey")); + org.junit.jupiter.api.Assertions.assertTrue(policy.allowed("operationName")); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the expected failure** + +Run: `./gradlew :modules:web:web-observability:test --tests '*WebObservabilityPolicyTest'` + +Expected: FAIL because observability cardinality and redaction policies are absent. + +- [ ] **Step 3: Implement the minimum production contract** + +```java +public final class WebMetricCardinalityPolicy { + private static final java.util.Set ALLOWED = java.util.Set.of( + "routeTemplate", + "http.method", + "http.status", + "outcome", + "apiVersion", + "operationName", + "problemCode", + "clientProfile" + ); + + public boolean allowed(String name) { + return ALLOWED.contains(name); + } +} +``` + +- [ ] **Step 4: Run the task test and its module contract suite** + +Run: `./gradlew :modules:web:web-observability:test` + +Expected: PASS with no skipped contract assertions and no new warnings. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/web/web-observability/src/main/java/io/backend/skeleton/web/observability/WebObservationConvention.java' 'modules/web/web-observability/src/main/java/io/backend/skeleton/web/observability/WebMetricCardinalityPolicy.java' 'modules/web/web-observability/src/main/java/io/backend/skeleton/web/observability/WebAccessLogEvent.java' 'modules/web/web-observability/src/main/java/io/backend/skeleton/web/observability/WebAccessLogger.java' 'modules/web/web-observability/src/main/java/io/backend/skeleton/web/observability/WebAuditEvent.java' 'modules/web/web-observability/src/main/java/io/backend/skeleton/web/observability/WebAuditPublisher.java' 'modules/web/web-observability/src/test/java/io/backend/skeleton/web/observability/WebObservabilityPolicyTest.java' +git commit -m "feat(web): add bounded HTTP observability and audit contracts" +``` + +### Task 55: 실제 Nginx Trusted Proxy·Prefix·Security 계약 + +**Files:** +- Create: `modules/web/web-testkit-contract/src/test/resources/nginx/nginx.conf` +- Create: `modules/web/web-testkit-contract/src/test/resources/nginx/docker-compose.yml` +- Create: `modules/web/web-testkit-contract/src/test/java/io/backend/skeleton/web/testkit/proxy/NginxProxyContractIT.java` +- Create: `modules/web/web-testkit-contract/src/test/java/io/backend/skeleton/web/testkit/proxy/NginxSecurityContractIT.java` +- Test: `modules/web/web-testkit-contract/src/test/java/io/backend/skeleton/web/testkit/proxy/NginxLimitAndTimeoutIT.java` + +**Interfaces:** +- Consumes: Tasks 24–25 proxy/external URL, 49 budget, 52 CORS/CSRF, real MVC/WebFlux applications +- Produces: Nginx TLS termination and `/api`·`/dev-api` prefix topology contract + +**Implementation requirements:** +- Nginx는 incoming Forwarded headers를 제거하고 authoritative values를 재설정한다. +- `https://hyeonworks.com/api`와 `/dev-api`가 application path에 정확히 한 번 매핑된다. +- Body/header/timeouts가 application profile과 의도한 계층에서 실패한다. +- Host/forwarded spoof, open redirect, prefix duplication을 차단한다. + +- [ ] **Step 1: Write the failing test** + +```java +class NginxSecurityContractIT { + @org.junit.jupiter.api.Test + void attackerCannotOverrideForwardedHost() { + var response = client.get("/dev-api/v1/external-uri") + .header("X-Forwarded-Host", "evil.example") + .execute(); + + org.junit.jupiter.api.Assertions.assertEquals(200, response.status()); + org.junit.jupiter.api.Assertions.assertEquals( + "https://hyeonworks.com/dev-api/v1/result", + response.body() + ); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the expected failure** + +Run: `./gradlew :modules:web:web-testkit-contract:nginxContract` + +Expected: FAIL because the Nginx topology and proxy contract task are absent. + +- [ ] **Step 3: Implement the minimum production contract** + +```nginx +events {} + +http { + server { + listen 8443 ssl; + ssl_certificate /etc/nginx/certs/test.crt; + ssl_certificate_key /etc/nginx/certs/test.key; + + location /dev-api/ { + proxy_set_header Forwarded ""; + proxy_set_header X-Forwarded-For ""; + proxy_set_header X-Forwarded-Host ""; + proxy_set_header X-Forwarded-Proto ""; + proxy_set_header X-Forwarded-Prefix ""; + + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Forwarded-Prefix /dev-api; + proxy_pass http://application/; + } + } +} +``` + +- [ ] **Step 4: Run the task test and its module contract suite** + +Run: `./gradlew :modules:web:web-testkit-contract:nginxContract` + +Expected: PASS with no skipped contract assertions and no new warnings. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/web/web-testkit-contract/src/test/resources/nginx/nginx.conf' 'modules/web/web-testkit-contract/src/test/resources/nginx/docker-compose.yml' 'modules/web/web-testkit-contract/src/test/java/io/backend/skeleton/web/testkit/proxy/NginxProxyContractIT.java' 'modules/web/web-testkit-contract/src/test/java/io/backend/skeleton/web/testkit/proxy/NginxSecurityContractIT.java' 'modules/web/web-testkit-contract/src/test/java/io/backend/skeleton/web/testkit/proxy/NginxLimitAndTimeoutIT.java' +git commit -m "test(web): certify Nginx proxy and security boundary" +``` + +### Task 56: Cross-stack Contract Testkit와 Sample Use Case + +**Files:** +- Create: `modules/web/web-testkit-core/src/main/java/io/backend/skeleton/web/testkit/contract/WebPlatformContractSuite.java` +- Create: `modules/web/web-testkit-core/src/main/java/io/backend/skeleton/web/testkit/contract/WebContractFixture.java` +- Create: `modules/web/web-testkit-mvc/src/main/java/io/backend/skeleton/web/testkit/mvc/MvcWebPlatformContractSuite.java` +- Create: `modules/web/web-testkit-webflux/src/main/java/io/backend/skeleton/web/testkit/webflux/WebFluxPlatformContractSuite.java` +- Create: `examples/web-platform-sample/build.gradle.kts` +- Create: `examples/web-platform-sample/src/main/java/io/backend/skeleton/examples/web/CreateDocumentUseCase.java` +- Create: `examples/web-platform-sample/src/main/java/io/backend/skeleton/examples/web/DocumentController.java` +- Modify: `settings.gradle.kts` +- Test: `modules/web/web-testkit-contract/src/test/java/io/backend/skeleton/web/testkit/contract/CrossStackParityTest.java` + +**Interfaces:** +- Consumes: Tasks 15–55 stable MVC/WebFlux/contract modules +- Produces: one reusable contract suite proving the same HTTP semantics across MVC and WebFlux + +**Implementation requirements:** +- 공통 suite가 status, headers, Problem, cursor, conditional, idempotency, operation, cache, security, budgets를 검증한다. +- Sample controller는 Repository가 아니라 Application Use Case만 호출한다. +- MVC와 WebFlux가 transport type은 달라도 wire contract는 동일하다. + +- [ ] **Step 1: Write the failing test** + +```java +class CrossStackParityTest { + @org.junit.jupiter.api.Test + void mvcAndWebFluxExposeSameProblemAndHeaderContract() { + var mvc = ContractResults.from(new MvcWebPlatformContractSuite().run()); + var webFlux = ContractResults.from(new WebFluxPlatformContractSuite().run()); + + org.junit.jupiter.api.Assertions.assertEquals( + mvc.normalizedWireResults(), + webFlux.normalizedWireResults() + ); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the expected failure** + +Run: `./gradlew :modules:web:web-testkit-contract:test --tests '*CrossStackParityTest'` + +Expected: FAIL because no shared cross-stack contract suite or sample application exists. + +- [ ] **Step 3: Implement the minimum production contract** + +```java +public interface WebPlatformContractSuite { + ContractRunResult run(WebContractFixture fixture); +} + +public final class StandardWebPlatformAssertions { + public void assertProblem( + ContractResponse response, + int status, + ProblemCode code) { + org.assertj.core.api.Assertions.assertThat(response.status()).isEqualTo(status); + org.assertj.core.api.Assertions.assertThat(response.problemCode()).isEqualTo(code); + org.assertj.core.api.Assertions.assertThat(response.contentType()) + .isEqualTo("application/problem+json"); + } +} +``` + +- [ ] **Step 4: Run the task test and its module contract suite** + +Run: `./gradlew :examples:web-platform-sample:test :modules:web:web-testkit-contract:test` + +Expected: PASS with no skipped contract assertions and no new warnings. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/web/web-testkit-core/src/main/java/io/backend/skeleton/web/testkit/contract/WebPlatformContractSuite.java' 'modules/web/web-testkit-core/src/main/java/io/backend/skeleton/web/testkit/contract/WebContractFixture.java' 'modules/web/web-testkit-mvc/src/main/java/io/backend/skeleton/web/testkit/mvc/MvcWebPlatformContractSuite.java' 'modules/web/web-testkit-webflux/src/main/java/io/backend/skeleton/web/testkit/webflux/WebFluxPlatformContractSuite.java' 'examples/web-platform-sample/build.gradle.kts' 'examples/web-platform-sample/src/main/java/io/backend/skeleton/examples/web/CreateDocumentUseCase.java' 'examples/web-platform-sample/src/main/java/io/backend/skeleton/examples/web/DocumentController.java' 'settings.gradle.kts' 'modules/web/web-testkit-contract/src/test/java/io/backend/skeleton/web/testkit/contract/CrossStackParityTest.java' +git commit -m "test(web): add cross-stack contract suite and sample" +``` + +### Task 57: Abuse·Performance·Graceful Shutdown Release Tests + +**Files:** +- Create: `modules/web/web-testkit-contract/src/test/java/io/backend/skeleton/web/testkit/performance/WebAbuseSuiteIT.java` +- Create: `modules/web/web-testkit-mvc/src/test/java/io/backend/skeleton/web/testkit/performance/TomcatLoadAndShutdownIT.java` +- Create: `modules/web/web-testkit-mvc/src/test/java/io/backend/skeleton/web/testkit/performance/JettyLoadAndShutdownIT.java` +- Create: `modules/web/web-testkit-webflux/src/test/java/io/backend/skeleton/web/testkit/performance/ReactorNettyLoadAndShutdownIT.java` +- Create: `modules/web/web-testkit-contract/src/test/resources/performance/web-load-profile.yaml` +- Modify: `build.gradle.kts` +- Test: `modules/web/web-testkit-contract/src/test/java/io/backend/skeleton/web/testkit/performance/WebPerformanceBudgetTest.java` + +**Interfaces:** +- Consumes: Tasks 49–56 budgets, admission, proxy, real servers, and contract suite +- Produces: header/URI/body/deep JSON/array/decompression/slow-client/flood/load/shutdown release gates + +**Implementation requirements:** +- 평균만이 아니라 p50/p95/p99, throughput, active/queued/rejected, heap, GC, threads, event-loop, DB pool, response write, disconnect를 기록한다. +- Slow client와 connection flood에서 memory와 queue가 bounded인지 검증한다. +- Graceful shutdown 중 신규 request 거부와 in-flight completion을 실제 server별로 검증한다. + +- [ ] **Step 1: Write the failing test** + +```java +class WebPerformanceBudgetTest { + @org.junit.jupiter.api.Test + void resultsStayInsideDeclaredResourceBounds() { + var result = WebLoadFixture.runStandardProfile(); + org.junit.jupiter.api.Assertions.assertTrue(result.maxHeapBytes() <= 512L * 1024 * 1024); + org.junit.jupiter.api.Assertions.assertTrue(result.maxQueuedRequests() <= 100); + org.junit.jupiter.api.Assertions.assertTrue(result.p99().compareTo( + java.time.Duration.ofSeconds(2) + ) < 0); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the expected failure** + +Run: `./gradlew webAbuseTest webPerformanceTest webGracefulShutdownTest` + +Expected: FAIL because release-grade abuse, performance, and shutdown tasks are absent. + +- [ ] **Step 3: Implement the minimum production contract** + +```kotlin +tasks.register("webAbuseTest") { + useJUnitPlatform { + includeTags("web-abuse") + } +} + +tasks.register("webPerformanceTest") { + useJUnitPlatform { + includeTags("web-performance") + } + shouldRunAfter("webAbuseTest") +} + +tasks.register("webGracefulShutdownTest") { + useJUnitPlatform { + includeTags("web-shutdown") + } +} +``` + +- [ ] **Step 4: Run the task test and its module contract suite** + +Run: `./gradlew webAbuseTest webPerformanceTest webGracefulShutdownTest` + +Expected: PASS with no skipped contract assertions and no new warnings. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/web/web-testkit-contract/src/test/java/io/backend/skeleton/web/testkit/performance/WebAbuseSuiteIT.java' 'modules/web/web-testkit-mvc/src/test/java/io/backend/skeleton/web/testkit/performance/TomcatLoadAndShutdownIT.java' 'modules/web/web-testkit-mvc/src/test/java/io/backend/skeleton/web/testkit/performance/JettyLoadAndShutdownIT.java' 'modules/web/web-testkit-webflux/src/test/java/io/backend/skeleton/web/testkit/performance/ReactorNettyLoadAndShutdownIT.java' 'modules/web/web-testkit-contract/src/test/resources/performance/web-load-profile.yaml' 'build.gradle.kts' 'modules/web/web-testkit-contract/src/test/java/io/backend/skeleton/web/testkit/performance/WebPerformanceBudgetTest.java' +git commit -m "test(web): add abuse performance and shutdown gates" +``` + +### Task 58: Final Starter·Admin·CI·Runbook·Stable Release Gate + +**Files:** +- Create: `modules/web/web-admin/src/main/java/io/backend/skeleton/web/admin/WebPlatformEndpoint.java` +- Create: `modules/web/web-admin/src/main/java/io/backend/skeleton/web/admin/WebPlatformSnapshot.java` +- Create: `modules/web/web-spring-boot-starter-mvc/src/main/java/io/backend/skeleton/web/mvc/autoconfigure/WebMvcStartupValidator.java` +- Create: `modules/web/web-spring-boot-starter-webflux/src/main/java/io/backend/skeleton/web/webflux/autoconfigure/WebFluxStartupValidator.java` +- Create: `.github/workflows/web-pr.yml` +- Create: `.github/workflows/web-nightly.yml` +- Create: `.github/workflows/web-release.yml` +- Create: `docs/web/support-matrix.md` +- Create: `docs/web/http-contract.md` +- Create: `docs/web/problem-catalog.md` +- Create: `docs/web/idempotency-and-completion-evidence.md` +- Create: `docs/web/durable-operation.md` +- Create: `docs/web/security-proxy-budget.md` +- Create: `docs/web/openapi-governance.md` +- Create: `docs/web/observability.md` +- Create: `docs/web/runbooks.md` +- Create: `docs/adr/ADR-WEB-001-controller-is-use-case-adapter.md` +- Create: `docs/adr/ADR-WEB-002-http-and-application-evidence-are-distinct.md` +- Create: `docs/adr/ADR-WEB-003-idempotency-commit-evidence.md` +- Create: `docs/adr/ADR-WEB-004-mvc-and-webflux-profile-separation.md` +- Create: `docs/adr/ADR-WEB-005-openapi-contract-governance.md` +- Modify: `build.gradle.kts` +- Test: `modules/web/web-testkit-contract/src/test/java/io/backend/skeleton/web/testkit/release/WebReleaseManifestTest.java` + +**Interfaces:** +- Consumes: Tasks 1–57 entire Stable platform +- Produces: validated starter configuration, admin snapshot, PR/nightly/release workflows, support matrix, ADRs, and stable release gate + +**Implementation requirements:** +- PR은 unit/contract/Tomcat/OpenAPI/architecture 검증을 실행한다. +- Nightly는 Jetty/Reactor Netty/Nginx/fault/abuse를 실행한다. +- Release는 모든 server, response-loss, performance, graceful shutdown, manifest/hash 검증을 실행한다. +- Startup validator는 mixed stack, unbounded budgets, prod GraphiQL/Swagger, untrusted forwarded mode, missing cursor key, unsafe idempotency를 차단한다. + +- [ ] **Step 1: Write the failing test** + +```java +class WebReleaseManifestTest { + @org.junit.jupiter.api.Test + void stableReleaseContainsEveryRequiredEvidence() { + var manifest = WebReleaseManifest.load(); + org.junit.jupiter.api.Assertions.assertTrue(manifest.has("tomcat-contract")); + org.junit.jupiter.api.Assertions.assertTrue(manifest.has("jetty-compat")); + org.junit.jupiter.api.Assertions.assertTrue(manifest.has("reactor-netty-contract")); + org.junit.jupiter.api.Assertions.assertTrue(manifest.has("nginx-contract")); + org.junit.jupiter.api.Assertions.assertTrue(manifest.has("response-loss-idempotency")); + org.junit.jupiter.api.Assertions.assertTrue(manifest.has("openapi-breaking")); + org.junit.jupiter.api.Assertions.assertTrue(manifest.has("performance-budget")); + } +} +``` + +- [ ] **Step 2: Run the focused test and verify the expected failure** + +Run: `./gradlew :modules:web:web-testkit-contract:test --tests '*WebReleaseManifestTest'` + +Expected: FAIL because final release evidence, startup validators, workflows, and runbooks are missing. + +- [ ] **Step 3: Implement the minimum production contract** + +```java +public record WebPlatformSnapshot( + String stack, + java.util.List routes, + java.util.List problems, + java.util.List operations, + java.util.Map budgets, + java.util.List startupWarnings) { +} + +@org.springframework.boot.actuate.endpoint.annotation.Endpoint(id = "webPlatform") +public final class WebPlatformEndpoint { + private final WebPlatformSnapshotProvider provider; + + @org.springframework.boot.actuate.endpoint.annotation.ReadOperation + public WebPlatformSnapshot snapshot() { + return provider.snapshotWithoutSecrets(); + } +} +``` + +- [ ] **Step 4: Run the task test and its module contract suite** + +Run: `./gradlew webStableCheck webOpenApiContract webAbuseTest webPerformanceTest webGracefulShutdownTest` + +Expected: PASS with no skipped contract assertions and no new warnings. + +- [ ] **Step 5: Commit the independently reviewable change** + +```bash +git add 'modules/web/web-admin/src/main/java/io/backend/skeleton/web/admin/WebPlatformEndpoint.java' 'modules/web/web-admin/src/main/java/io/backend/skeleton/web/admin/WebPlatformSnapshot.java' 'modules/web/web-spring-boot-starter-mvc/src/main/java/io/backend/skeleton/web/mvc/autoconfigure/WebMvcStartupValidator.java' 'modules/web/web-spring-boot-starter-webflux/src/main/java/io/backend/skeleton/web/webflux/autoconfigure/WebFluxStartupValidator.java' '.github/workflows/web-pr.yml' '.github/workflows/web-nightly.yml' '.github/workflows/web-release.yml' 'docs/web/support-matrix.md' 'docs/web/http-contract.md' 'docs/web/problem-catalog.md' 'docs/web/idempotency-and-completion-evidence.md' 'docs/web/durable-operation.md' 'docs/web/security-proxy-budget.md' 'docs/web/openapi-governance.md' 'docs/web/observability.md' 'docs/web/runbooks.md' 'docs/adr/ADR-WEB-001-controller-is-use-case-adapter.md' 'docs/adr/ADR-WEB-002-http-and-application-evidence-are-distinct.md' 'docs/adr/ADR-WEB-003-idempotency-commit-evidence.md' 'docs/adr/ADR-WEB-004-mvc-and-webflux-profile-separation.md' 'docs/adr/ADR-WEB-005-openapi-contract-governance.md' 'build.gradle.kts' 'modules/web/web-testkit-contract/src/test/java/io/backend/skeleton/web/testkit/release/WebReleaseManifestTest.java' +git commit -m "docs(web): add stable release matrix and runbooks" +``` + +## 3. 계획 자체 검증 체크리스트 + +- [ ] Task 1~58 번호가 연속적이다. +- [ ] 모든 Task에 Files, Interfaces, 구현 불변 조건, 실패 테스트, 실패 확인, 구현, 통과 확인, commit이 있다. +- [ ] later Task가 사용하는 핵심 공개 타입은 earlier Task에서 정의된다. +- [ ] Stable 모듈이 Advanced·Experimental 모듈에 의존하지 않는다. +- [ ] MVC와 WebFlux가 동일한 wire contract suite를 통과한다. +- [ ] Problem body의 status와 실제 HTTP status가 일치한다. +- [ ] 204·304에 body가 없다. +- [ ] Cursor는 HMAC, version, query profile, filter fingerprint를 검증한다. +- [ ] If-Match 실패는 412이며 business conflict 409와 분리된다. +- [ ] Redis `COMPLETED`만으로 JPA commit을 증명하지 않는다. +- [ ] business mutation과 authoritative idempotency evidence가 같은 DB transaction에서 commit된다. +- [ ] commit 후 response-loss fault에서 side effect가 한 번만 발생한다. +- [ ] durable acceptance 이전에 202를 반환하지 않는다. +- [ ] trusted proxy가 아닌 client의 Forwarded header를 거부한다. +- [ ] CORS `* + credentials`와 무제한 budget·queue가 startup/test에서 차단된다. +- [ ] 실제 Tomcat·Jetty·Reactor Netty·Nginx·shutdown gate가 CI에 연결된다. +- [ ] 문서에 미확정 표식과 빈 구현 지시가 없다. + +## 4. 실행 인계 + +권장 실행 방식은 `superpowers:subagent-driven-development`다. 각 Task마다 새 작업자를 사용하고 specification review와 code-quality review를 분리한다. + +동일 세션에서 실행할 경우 `superpowers:executing-plans`를 사용하고 다음 checkpoint에서 전체 test·diff·contract artifact를 확인한다. + +```text +Checkpoint A: Task 1–14 +Checkpoint B: Task 15–30 +Checkpoint C: Task 31–43 +Checkpoint D: Task 44–55 +Checkpoint E: Task 56–58 +``` diff --git a/docs/web-superpowers-package/docs/superpowers/specs/2026-08-13-web-inbound-http-api-execution-platform-design.md b/docs/web-superpowers-package/docs/superpowers/specs/2026-08-13-web-inbound-http-api-execution-platform-design.md new file mode 100644 index 00000000..f8ea8c5e --- /dev/null +++ b/docs/web-superpowers-package/docs/superpowers/specs/2026-08-13-web-inbound-http-api-execution-platform-design.md @@ -0,0 +1,1656 @@ +# 인바운드 HTTP API 실행 플랫폼 `web` 설계서 + +> 기준일: 2026-08-13 +> 기준 기술선: Java 21, Spring Boot 4.1 BOM, Spring Framework 7.0.x +> 대상: Backend Skeleton의 인바운드 HTTP 실행 계층 +> 원본 요구사항: `인바운드 HTTP API 실행 플랫폼 web 심층 리서치` + +--- + +## 0. 확정 경계 요약 + +이번 `web`은 Spring MVC 설정, 공통 `ControllerAdvice`, Swagger UI를 한데 묶은 편의 라이브러리가 아니다. + +```text +HTTP 요청 수신 +→ 신뢰 프록시 정규화 +→ CORS·Trace·Authentication +→ Global Budget·Admission +→ Route·API Version 선택 +→ Route Policy +→ Decode·Binding·Transport Validation +→ Controller Adapter +→ Application Use Case +→ Application Commit Evidence +→ Response Mapping +→ Cache·Conditional Response +→ Problem Details 또는 Streaming Terminal +→ Metric·Access Log·Audit +``` + +플랫폼의 핵심 산출물은 “Controller가 호출됐다”가 아니라 다음 세 축의 증거다. + +```text +Request Evidence +Application Evidence +Response Evidence +``` + +```text +HTTP response write 완료 +≠ client가 response를 관찰함 + +Application method 반환 +≠ database transaction commit + +HTTP 5xx·connection reset +≠ mutation이 실행되지 않음 +``` + +상태 변경 재호출의 안전성은 HTTP status가 아니라 **Idempotency Scope + Semantic Fingerprint + Authoritative Commit Evidence**에서 확보한다. + +--- + +## 1. 문서 목적 + +이 설계서는 구현 중 다시 결정해서는 안 되는 다음 항목을 고정한다. + +- `web`과 Security·JPA·MongoDB·Redis·Messaging·Fileserver의 책임 경계 +- Stable·Advanced·Experimental 기능 등급 +- MVC·WebFlux 실행 profile +- HTTP method·status·header·media type 계약 +- DTO·JSON·Validation 정책 +- RFC 9457 Problem Details wire contract +- Path major API version과 deprecation +- Cursor·filter·sort·projection 계약 +- ETag·If-Match·If-None-Match +- Idempotency·Application Commit·Response Loss 복구 +- Durable Operation과 `202 Accepted` +- Cache·CORS·CSRF·Forwarded Header·Nginx +- Request budget·rate limit·admission +- OpenAPI·Route Inventory·Observability +- 실제 server·proxy·fault·performance release gate + +이 설계서는 도메인 URI, 실제 DTO field, 구체 업무 권한, 업무별 TTL을 정의하지 않는다. 해당 값은 각 애플리케이션 모듈이 본 플랫폼의 catalog와 SPI에 등록한다. + +--- + +## 2. 플랫폼 핵심 불변식 + +### 2.1 일곱 가지 불변식 + +1. **HTTP status는 업무 결과를 숨기지 않는다.** +2. **Controller는 Application Use Case Adapter다.** +3. **Application Commit과 HTTP Response Delivery는 다른 증거다.** +4. **Mutation retry는 status가 아니라 idempotency evidence로 판단한다.** +5. **ETag/If-Match와 Idempotency는 서로 다른 문제를 해결한다.** +6. **Response commit 이후에는 Problem Details로 status를 다시 바꿀 수 없다.** +7. **Cache·Proxy·Security·OpenAPI·Observability는 공개 HTTP 계약의 일부다.** + +### 2.2 금지되는 설계 + +```text +Controller → JpaRepository +Controller → EntityManager +Controller → MongoTemplate +Controller → WebClient/RestClient retry +Controller → Kafka/Rabbit ACK +Controller → MinIO SDK +Controller @Transactional +Entity/Document request binding +Entity/Document response serialization +전역 ApiResponse +무제한 Map +``` + +--- + +## 3. 기술 기준선 + +| 영역 | Stable 기준 | 정책 | +|---|---|---| +| Java | 21 | 플랫폼 최소선 | +| Spring Boot | 4.1 BOM | 모든 Spring·Server 버전 Source of Truth | +| Spring Framework | Boot 관리 7.0.x | 직접 override 금지 | +| MVC | Stable 기본 | 일반 업무 API | +| MVC server | Tomcat 기본 | 실제 server release gate | +| Jetty | Stable compatibility | 동일 wire contract | +| WebFlux | Stable 선택 profile | 완전 reactive chain | +| WebFlux server | Reactor Netty | 실제 server gate | +| Virtual Thread MVC | Advanced | 별도 부하·pinning 검증 | +| Problem | RFC 9457 | `application/problem+json` | +| HTTP | RFC 9110·9111 | method/status/cache/conditional | +| OpenAPI | 3.1.2 Stable | 3.2는 Experimental | +| Test | mock + actual server + Nginx | mock-only Stable 금지 | + +### 3.1 실행 Profile + +```text +PLATFORM_THREAD_MVC +→ Stable default + +VIRTUAL_THREAD_MVC +→ Advanced opt-in + +REACTIVE_WEBFLUX +→ Stable separate profile + +WEBFLUX_BLOCKING_BRIDGE +→ Advanced, bounded and registered +``` + +### 3.2 Starter 선택 + +```text +web-spring-boot-starter-mvc +XOR +web-spring-boot-starter-webflux +``` + +둘 다 classpath에 있으면 startup을 실패시킨다. Spring Boot가 암묵적으로 MVC를 선택하게 두지 않는다. + +--- + +## 4. 책임 경계 + +| 연계 모듈 | `web` 소유 | 상대 모듈 소유 | +|---|---|---| +| Security | HTTP authentication context, CORS·CSRF integration | token/session 검증, actor·permission 원천 | +| HTTP Client | inbound HTTP | outbound HTTP, retry, TLS | +| GraphQL | endpoint edge integration | schema·resolver·GraphQL error | +| gRPC | 일반 HTTP resource API | Protobuf RPC | +| WebSocket | upgrade auth/context | bidirectional connection protocol | +| Fileserver | metadata·ticket DTO | binary upload/download·Range | +| JPA·MongoDB | Use Case 호출 | transaction·query·lock | +| Redis | HTTP rate/idempotency SPI 호출 | atomic counter·TTL | +| Messaging | 202·operation representation | durable publish·ACK·DLQ | +| Notification | notification request representation | provider delivery lifecycle | + +### 4.1 허용 호출 구조 + +```text +HTTP +→ Web Edge Policy +→ Controller Adapter +→ Request DTO +→ Application Command / Query +→ Application Use Case +→ Persistence / Integration +→ Application Result + Commit Evidence +→ Response DTO / Operation Resource +→ HTTP Status + Header + Representation +``` + +--- + +## 5. 공개 계층 + +| 계층 | 이름 | 범위 | +|---|---|---| +| W1 | Standard HTTP API | Controller, DTO, validation, Problem, version, pagination, OpenAPI | +| W2 | Advanced HTTP API | idempotency, precondition, durable operation, admission | +| W3 | Transport Extension | WebFlux, virtual threads, functional endpoint, streaming | +| W4 | Contract/Admin Plane | route inventory, OpenAPI publish/diff, usage, audit | + +### 5.1 Stable 범위 + +```text +MVC Controller/DTO +Tomcat +Jetty compatibility +WebFlux separate profile +Validation +RFC 9457 +Path major version +Slice·Keyset cursor +ETag·Conditional GET +If-Match mutation +OpenAPI 3.1.2 +Registered mutation idempotency +Durable 202 operation +``` + +### 5.2 Advanced + +```text +Virtual Thread MVC +Controlled blocking bridge +JSON Merge Patch +JSON Patch +SSE +NDJSON +JSON Sequence +Functional WebFlux +CBOR/XML +``` + +### 5.3 Experimental + +```text +OpenAPI 3.2 +RateLimit draft headers +``` + +### 5.4 비지원 + +```text +Raw ServletRequest / ServerWebExchange in domain +Controller transaction +Entity/Document wire model +WebFlux event-loop blocking JPA +Web-owned durable SSE history +Large binary transfer +Bidirectional messaging +Global ApiResponse +``` + +--- + +## 6. 모듈 구조 + +### 6.1 Stable + +```text +modules/web/ +├── web-core-api +├── web-contract +├── web-validation +├── web-error +├── web-pagination +├── web-idempotency +├── web-idempotency-jpa +├── web-idempotency-redis +├── web-versioning +├── web-security-integration +├── web-observability +├── web-openapi +├── web-mvc +├── web-webflux +├── web-admin +├── web-operation-jpa +├── web-operation-messaging +├── web-spring-boot-starter-mvc +├── web-spring-boot-starter-webflux +├── web-testkit-core +├── web-testkit-mvc +├── web-testkit-webflux +└── web-testkit-contract +``` + +### 6.2 Advanced + +```text +modules/web-advanced/ +├── web-advanced-bootstrap +├── web-streaming-core +├── web-streaming-mvc +├── web-streaming-webflux +├── web-patch +├── web-functional-webflux +├── web-codec-cbor +├── web-codec-xml +├── web-openapi-32-experimental +├── web-ratelimit-draft-experimental +└── web-advanced-testkit +``` + +### 6.3 의존 방향 + +```text +web-core-api + ↑ +contract / validation / error / pagination / idempotency / versioning + ↑ +mvc / webflux / openapi / admin + ↑ +starter-mvc OR starter-webflux +``` + +`web-core-api`에는 다음 type을 넣지 않는다. + +```text +HttpServletRequest +HttpServletResponse +ServerWebExchange +Mono +Flux +DataBuffer +ProblemDetail +``` + +--- + +## 7. 인바운드 실행 파이프라인 + +### 7.1 의미 단계 + +```text +EDGE + forwarded normalization + CORS + request-id / trace + authentication + global budget / admission + +ROUTING + route mapping + API version + +HANDLER POLICY + route auth + route rate/admission + idempotency requirement + precondition policy + +BINDING + decode + bind + transport validation + +APPLICATION + controller adapter + application use case + object/tenant authorization + transaction + +OUTBOUND + response mapping + cache / ETag + Problem if uncommitted + metric / access log / audit +``` + +### 7.2 실행 증거 + +```java +public enum WebRequestPhase { + HTTP_RECEIVED, + REQUEST_NORMALIZED, + ROUTE_SELECTED, + REQUEST_BOUND, + REQUEST_VALIDATED, + REQUEST_ADMITTED +} + +public enum WebApplicationEvidence { + NOT_STARTED, + APPLICATION_STARTED, + APPLICATION_ROLLED_BACK, + APPLICATION_COMMITTED, + APPLICATION_UNKNOWN +} + +public enum WebResponseEvidence { + NOT_COMMITTED, + RESPONSE_HEADERS_COMMITTED, + RESPONSE_PARTIALLY_WRITTEN, + RESPONSE_WRITE_COMPLETED_LOCALLY, + CLIENT_OBSERVATION_UNKNOWN +} + +public record WebExecutionEvidence( + WebRequestPhase requestPhase, + WebApplicationEvidence applicationEvidence, + WebResponseEvidence responseEvidence) { +} +``` + +### 7.3 해석 + +| Evidence | Application | Commit | 재호출 | +|---|---:|---:|---| +| request rejected | 미진입 | 없음 | 수정 후 가능 | +| application started | 진입 | 불명 | mutation 자동 재호출 금지 | +| rolled back | 진입 | 없음 | profile에 따라 가능 | +| committed | 진입 | 있음 | replay/reconcile | +| headers committed | 가능 | 별도 evidence | status만으로 판정 금지 | +| partial response | 가능 | 별도 evidence | resume contract 필요 | +| local write complete | 가능 | 별도 evidence | client observation 아님 | + +--- + +## 8. Core 공개 타입 + +```java +public record WebOperationName(String value) { + public WebOperationName { + if (value == null || !value.matches("[a-z][a-z0-9.-]{2,127}")) { + throw new IllegalArgumentException("invalid web operation name"); + } + } +} + +public record WebRouteId(String value) {} +public record WebRequestId(String value) {} +public record WebTraceId(String value) {} +public record ApiMajorVersion(int value) {} +``` + +```java +public record WebRequestContext( + WebRequestId requestId, + WebTraceId traceId, + WebOperationName operationName, + ApiMajorVersion apiVersion, + ActorContext actor, + TenantContext tenant, + Locale locale, + Instant receivedAt, + Instant deadline, + ExternalRequestContext externalRequest) { +} +``` + +```java +public enum MutationKind { + READ_ONLY, + CREATE, + REPLACE, + PARTIAL_UPDATE, + DELETE, + COMMAND, + DURABLE_ASYNC +} +``` + +```java +public record WebOperationProfile( + WebOperationName operationName, + HttpMethodSemantic method, + MutationKind mutationKind, + WebBudgetProfileName requestBudget, + AuthorizationProfileName authorization, + IdempotencyPolicy idempotency, + PreconditionPolicy precondition, + CachePolicyName cachePolicy, + AdmissionProfileName admission, + ResponseProfileName responseProfile) { +} +``` + +모든 공개 route는 하나의 등록된 `WebOperationProfile`을 가져야 한다. + +--- + +## 9. URI·Method 계약 + +### 9.1 URI + +```text +/api/v1/documents/{documentId} +``` + +정책: + +| 항목 | 정책 | +|---|---| +| case | sensitive | +| trailing slash | canonical form 하나 | +| duplicate slash | reject/edge normalize | +| encoded slash | 기본 reject | +| matrix parameter | 비지원 | +| ID | opaque string | +| DB column 노출 | 금지 | +| user redirect target | raw 사용 금지 | + +### 9.2 Method + +| Method | 용도 | 등급 | +|---|---|---| +| GET | query/resource | Stable | +| HEAD | GET metadata | Stable | +| POST | create/command | Stable | +| PUT | replace/create-known-URI | Stable | +| PATCH | partial update | Stable typed DTO | +| DELETE | deletion | Stable | +| OPTIONS | CORS·Allow | Stable | +| TRACE | blocked | Unsupported | +| CONNECT | blocked | Unsupported | + +### 9.3 PATCH + +| 방식 | 등급 | +|---|---| +| typed DTO | Stable 기본 | +| JSON Merge Patch RFC 7396 | Advanced | +| JSON Patch RFC 6902 | Advanced | +| Map | Unsupported | + +--- + +## 10. Status·Header 계약 + +### 10.1 성공 + +| 상황 | Status | 필수 | +|---|---:|---| +| 조회 | 200 | representation | +| 생성 | 201 | Location | +| durable async | 202 | operation Location | +| body 없음 | 204 | body 금지 | +| partial content | 206 | Fileserver 중심 | +| not modified | 304 | body 금지 | + +### 10.2 오류 + +| 상황 | Status | +|---|---:| +| malformed JSON·conversion | 400 | +| unauthenticated | 401 | +| unauthorized | 403 | +| not found·existence hidden | 404 | +| method not allowed | 405 | +| not acceptable | 406 | +| gone | 410 | +| payload too large | 413 | +| unsupported media | 415 | +| business conflict | 409 | +| precondition failed | 412 | +| transport semantic validation | 422 | +| rate limit | 429 | +| admission/capacity | 503 | +| upstream bad response | 502 | +| upstream timeout | 504 | +| internal | 500 | + +### 10.3 Header Catalog + +```text +Location +Content-Location +ETag +Last-Modified +If-Match +If-None-Match +If-Modified-Since +If-Unmodified-Since +Cache-Control +Vary +Retry-After +Deprecation +Sunset +Link +Allow +Content-Type +Content-Language +``` + +--- + +## 11. JSON·Binding·Validation + +### 11.1 Stable JSON Profile + +```java +public record WebJsonProfile( + boolean rejectUnknownProperties, + boolean rejectDuplicateKeys, + boolean rejectTrailingTokens, + boolean caseSensitiveEnums, + boolean rejectScalarCoercion, + int maxDepth, + int maxArrayElements, + int maxStringBytes) { +} +``` + +기본: + +```text +unknown mutation field reject +duplicate key reject +trailing token reject +unknown enum reject +enum case-sensitive +empty string → null disabled +number ↔ string disabled +polymorphism allowlist only +``` + +### 11.2 Validation 계층 + +```text +Parsing +→ JSON/URI/header syntax + +Transport Validation +→ length/range/format/count/cross-field + +Application Validation +→ existence/state/permission/domain invariant +``` + +Transport validator는 DB·HTTP·Messaging에 접근하지 않는다. + +### 11.3 Wire Manifest + +| Java | JSON | +|---|---| +| Instant | UTC RFC 3339 | +| OffsetDateTime | offset ISO | +| LocalDate | YYYY-MM-DD | +| Duration | ISO-8601 | +| UUID | canonical | +| BigDecimal | precision·scale contract | +| long | optional string profile | +| Enum | stable wire value | +| URI | normalized | +| Locale | BCP 47 | + +--- + +## 12. Controller Adapter + +```java +@RestController +@RequestMapping("/api/v1/documents") +final class DocumentHttpController { + private final CreateDocumentUseCase createDocument; + + @PostMapping + ResponseEntity create( + @Valid @RequestBody CreateDocumentRequest request, + WebRequestContext context) { + + var result = createDocument.execute( + request.toCommand(context.actor(), context.tenant()) + ); + + return ResponseEntity + .created(URI.create("/api/v1/documents/" + result.documentId())) + .body(DocumentResponse.from(result)); + } +} +``` + +Controller 허용: + +```text +transport input +DTO mapping +context +use case invocation +response mapping +``` + +Controller 금지: + +```text +transaction +repository composition +retry +broker settlement +binary storage +business state machine +``` + +--- + +## 13. RFC 9457 Problem Details + +```java +public record WebProblem( + URI type, + String title, + int status, + String detail, + URI instance, + ProblemCode code, + String traceId, + List errors) { +} +``` + +### 13.1 Problem Code + +```text +MALFORMED_REQUEST 400 +BINDING_FAILED 400 +VALIDATION_FAILED 422 +AUTHENTICATION_REQUIRED 401 +ACCESS_DENIED 403 +RESOURCE_NOT_FOUND 404 +METHOD_NOT_ALLOWED 405 +NOT_ACCEPTABLE 406 +RESOURCE_GONE 410 +RESOURCE_CONFLICT 409 +PRECONDITION_FAILED 412 +REQUEST_TOO_LARGE 413 +UNSUPPORTED_MEDIA_TYPE 415 +IDEMPOTENCY_KEY_REQUIRED 400 +IDEMPOTENCY_KEY_REUSED 422 +IDEMPOTENCY_REQUEST_IN_PROGRESS 409 +RATE_LIMITED 429 +ADMISSION_REJECTED 503 +DEPENDENCY_FAILURE 502/503 profile +DEPENDENCY_TIMEOUT 504 +RESPONSE_TOO_LARGE 500/stream termination profile +INTERNAL_ERROR 500 +``` + +금지 정보: + +```text +stack trace +exception class +SQL/JPQL/Mongo query +internal host +access token/cookie +provider raw body +PII +``` + +Factory만 Problem을 만들고 body status와 실제 response status를 검증한다. + +--- + +## 14. API Versioning·Deprecation + +### 14.1 Version + +```text +Major → /api/v1 +Minor/Patch → additive compatibility +``` + +Unknown major version은 catalog policy에 따라 404 Problem으로 처리한다. + +### 14.2 Deprecation + +```text +Deprecation +Sunset +Link rel=deprecation +``` + +제거 gate: + +```text +OpenAPI breaking diff ++ usage threshold ++ sunset elapsed ++ client owner confirmation ++ consumer contract ++ rollback plan +``` + +--- + +## 15. Collection Query + +### 15.1 방식 + +| 방식 | 용도 | +|---|---| +| Page | 작은 admin·total count | +| Slice | 일반 목록 | +| Keyset Cursor | 대규모·high-write | +| offset/limit | 제한형 | + +### 15.2 Catalog + +```java +public interface SortFieldCatalog { + SortField resolve(String externalName); +} + +public interface FilterFieldCatalog { + FilterField resolve(String externalName); +} + +public interface FilterOperatorCatalog { + FilterOperator resolve(String externalName); +} + +public interface ProjectionProfileCatalog { + ProjectionProfile resolve(String externalName); +} +``` + +### 15.3 Cursor + +```java +public record WebCursorPayload( + int version, + String queryProfile, + Map sortValues, + String uniqueTieBreaker, + String filterFingerprint, + Instant issuedAt, + String keyId) { +} +``` + +HMAC으로 인증하며 다음을 거부한다. + +```text +tampered MAC +unknown version +wrong query profile +filter mismatch +missing unique tie-breaker +page hard maximum violation +``` + +--- + +## 16. Conditional Request + +```java +public record EntityTag(String value, boolean weak) {} +``` + +```text +GET/HEAD + matching If-None-Match +→ 304 + +PUT/PATCH/DELETE + mismatching If-Match +→ 412 + +PUT + If-None-Match: * + exists +→ 412 +``` + +```text +If-Match +→ 내가 읽은 revision이 최신인가? + +Idempotency +→ 같은 command를 이미 실행했는가? +``` + +둘은 함께 필요할 수 있다. + +--- + +## 17. Idempotency + +### 17.1 Scope + +```java +public record IdempotencyScope( + TenantFingerprint tenant, + ActorFingerprint actor, + WebOperationName operationName, + IdempotencyKey key) { +} +``` + +### 17.2 상태 + +```java +public enum IdempotencyState { + PROCESSING, + APPLICATION_COMMITTED, + COMPLETED_REPLAYABLE, + FAILED_RETRYABLE, + COMPLETION_UNKNOWN, + EXPIRED +} +``` + +### 17.3 SPI + +```java +public interface IdempotencyStore { + IdempotencyClaim claim( + IdempotencyScope scope, + RequestFingerprint fingerprint, + Instant expiresAt); + + void recordApplicationCommitted( + IdempotencyScope scope, + CommitEvidence evidence); + + void complete( + IdempotencyScope scope, + ResponseSnapshot snapshot, + Instant expiresAt); + + Optional find(IdempotencyScope scope); +} +``` + +### 17.4 Semantic Fingerprint + +```text +operation name +normalized path IDs +semantic command DTO +selected header allowlist +``` + +Raw JSON bytes를 hash하지 않는다. + +### 17.5 Adapter + +```text +web-idempotency-jpa +→ business mutation + authoritative evidence same DB transaction + +web-idempotency-redis +→ concurrent claim + replay cache +→ sole DB commit evidence 금지 +``` + +### 17.6 동일 key 처리 + +```text +same key + same fingerprint + PROCESSING +→ 409 + +same key + same fingerprint + COMPLETED +→ original response replay + +same key + different fingerprint +→ 422 +``` + +--- + +## 18. Commit 후 Response Loss + +필수 fault scenario: + +```text +POST +→ Application transaction COMMIT +→ response write 전 TCP reset +→ client IOException +→ same idempotency key retry +→ previous result replay +→ business side effect count = 1 +``` + +서버가 response를 썼다는 사실로 client 관측을 증명할 수 없다. 따라서 다음 중 하나가 필요하다. + +```text +Idempotency +Client-generated resource ID +Conditional create +Durable Operation +Reconciliation GET +``` + +--- + +## 19. Durable Operation과 202 + +```java +public enum OperationStatus { + PENDING, + RUNNING, + SUCCEEDED, + FAILED, + CANCELED, + EXPIRED +} +``` + +```java +public record OperationResource( + OperationId operationId, + OperationStatus status, + Instant createdAt, + Optional startedAt, + Optional completedAt, + Optional progress, + Optional resultLocation, + Optional problem, + Optional retryAfter, + Instant expiresAt) { +} +``` + +### 19.1 202 조건 + +```text +durable operation row commit +OR +business DB transaction + outbox commit + +THEN +202 Accepted +``` + +금지: + +```text +Controller → @Async → 202 +``` + +### 19.2 HTTP + +```text +POST /api/v1/exports +→ 202 + Location: /api/v1/operations/{id} + +GET /api/v1/operations/{id} +DELETE /api/v1/operations/{id} +``` + +--- + +## 20. HTTP Cache + +| Profile | Header | +|---|---| +| sensitive | private, no-store | +| public immutable | public, max-age, immutable | +| public mutable | ETag + revalidation | +| browser private | private, max-age | +| authenticated shared | explicit review only | + +```text +no-cache +→ store 가능, reuse 전 validation + +no-store +→ 저장 금지 +``` + +`Vary`에는 실제 representation을 바꾸는 bounded header만 넣는다. + +--- + +## 21. MVC·WebFlux + +### 21.1 MVC + +```text +blocking stack +JPA/blocking Mongo/blocking SDK +dedicated async/streaming executor +actual Tomcat gate +Jetty compatibility +``` + +### 21.2 WebFlux + +```text +reactive stack +Reactor Context +event-loop blocking prohibited +actual Reactor Netty gate +``` + +### 21.3 Blocking bridge + +Stable에서는 금지한다. Advanced에서 registered operation, bounded concurrency, queue timeout, cancellation을 가진 bridge만 허용한다. + +--- + +## 22. Streaming + +### 22.1 범위 + +```text +SSE +NDJSON +JSON Sequence +Async single response +``` + +범위 밖: + +```text +WebSocket bidirectional +Messaging durability +Fileserver binary +``` + +### 22.2 Commit 이후 오류 + +```text +uncommitted +→ Problem Details + 4xx/5xx + +committed +→ terminal stream error record if possible +→ otherwise abrupt close evidence +``` + +### 22.3 Policy + +```text +heartbeat +idle timeout +max age +max buffered items +slow consumer close +cancellation +shutdown drain +``` + +Durable replay는 Messaging/Event Log에 위임한다. + +--- + +## 23. Security + +### 23.1 CORS + +```text +exact origin allowlist +allowed methods +allowed headers +exposed headers +credential mode +preflight max age +``` + +금지: + +```text +origin reflection +* + credentials +production all-path unrestricted CORS +``` + +### 23.2 CSRF + +| Credential | CSRF | +|---|---| +| Session/BFF cookie | required | +| cookie+bearer | cookie mutation protected | +| Authorization header only | explicit threat review | +| service-to-service bearer | normally not browser CSRF | + +### 23.3 Authorization + +```text +Authentication +→ Route/Function permission +→ Use Case permission +→ Object authorization +→ Property authorization +→ Tenant isolation +``` + +Route role check가 object authorization을 대신하지 않는다. + +--- + +## 24. Proxy·Forwarded Header + +### 24.1 Trusted Nginx + +```text +Internet +→ Nginx removes incoming Forwarded/X-Forwarded-* +→ Nginx sets authoritative values +→ internal trusted connection +→ Spring normalization +``` + +### 24.2 External Context + +```java +public record ExternalRequestContext( + String scheme, + String host, + int port, + String prefix, + String clientAddress) { +} +``` + +Raw `Host`/`X-Forwarded-Host`로 absolute URI를 만들지 않는다. + +### 24.3 Prefix + +```text +production /api +development /dev-api +``` + +Prefix는 한 번만 적용한다. + +--- + +## 25. Request Budget·Rate Limit·Admission + +### 25.1 Budget + +```java +public record WebRequestBudget( + int maxUriBytes, + int maxHeaderBytes, + int maxQueryParameters, + long maxBodyBytes, + int maxJsonDepth, + int maxArrayElements, + int maxMultipartParts, + Duration maxExecutionTime, + long maxResponseBytes) { +} +``` + +Standard initial profile: + +```text +URI 8 KiB +headers 16 KiB +query params 100 +JSON body 1 MiB +large JSON ≤8 MiB opt-in +JSON depth 64 +array elements 1000 +multipart parts 20 +sync hard time ≤30s +``` + +### 25.2 Rate Limit + +```text +actor/IP/tenant/route quota +→ 429 +→ Retry-After +``` + +### 25.3 Admission + +```text +global writes +expensive query concurrency +bounded queue timeout +→ 503 +``` + +Rate limit과 admission은 별도다. + +--- + +## 26. Filter·Interceptor 순서 + +### 26.1 MVC + +```text +Servlet Filter +→ Security Filter Chain +→ Handler Mapping +→ HandlerInterceptor +→ ArgumentResolver +→ Controller +→ ResponseBodyAdvice +→ ExceptionHandler +``` + +### 26.2 WebFlux + +```text +WebFilter +→ Security WebFilter +→ Handler Mapping +→ ArgumentResolver +→ Handler +→ Result Handler +→ Error WebExceptionHandler +``` + +### 26.3 Body 처리 + +금지: + +```text +Filter reads entire body +→ String +→ hash/log +→ controller +``` + +권장: + +```text +bounded decode +→ typed DTO +→ deterministic semantic fingerprint +→ idempotency +→ use case +``` + +--- + +## 27. OpenAPI·Route Inventory + +### 27.1 Stable artifact + +```text +OpenAPI 3.1.2 +``` + +### 27.2 Route + +```java +public record WebRouteContract( + WebRouteId routeId, + WebOperationName operationName, + ApiMajorVersion apiVersion, + String method, + String pathTemplate, + Set consumes, + Set produces, + boolean deprecated, + Optional sunsetAt) { +} +``` + +### 27.3 Release Gate + +```text +OpenAPI lint +schema validation +breaking diff +generated client compile +route inventory match +problem catalog match +deprecation usage +consumer contract +``` + +Swagger UI: + +```text +Local allow +Dev authenticated +Staging admin +Prod off/admin +``` + +--- + +## 28. Observability + +### 28.1 Metrics + +```text +http.server.requests +active requests +queued/rejected +request/response bytes +validation failures +problem code +rate/admission reject +idempotency replay +operation state +stream duration +disconnect +``` + +허용 tags: + +```text +routeTemplate +method +status +outcome +apiVersion +operationName +problemCode +clientProfile +``` + +금지: + +```text +full URL +query string +user/tenant/resource ID +idempotency key +token +cookie +body +``` + +### 28.2 Access Log + +```text +requestId +traceId +method +route template +status +duration +bytes +actor fingerprint +normalized client address +``` + +### 28.3 Audit + +```text +admin endpoint +force delete +redrive +permission change +sunset change +idempotency override +``` + +--- + +## 29. Startup Validation + +다음은 startup failure다. + +```text +MVC+WebFlux starter simultaneous +Production unbounded budget +Production Swagger/GraphiQL public +Production trust-all forwarded mode +missing cursor HMAC key +unknown operation profile +duplicate route contract +idempotency required without authoritative adapter +Redis marked as sole DB commit evidence +durable async without durable store/outbox +WebFlux blocking dependency without Advanced bridge +``` + +--- + +## 30. 테스트 전략 + +### 30.1 Contract + +```text +method +content type / accept +binding / validation +status / headers / body +Problem +HEAD +201/202/204/304 +pagination/cursor +conditional +cache +``` + +### 30.2 Idempotency + +```text +same key concurrent +same key same payload +same key different payload +processing crash +commit response loss +replay +TTL expiry +completion unknown +``` + +### 30.3 Security·Proxy + +```text +CORS +CSRF +object/property auth +host injection +forwarded spoof +prefix duplicate +open redirect +body/header/path limits +``` + +### 30.4 Abuse + +```text +header/URI/body limit +deep JSON +huge arrays +decompression bomb +slow request +slow response consumer +connection flood +request/write/query flood +``` + +### 30.5 Server Matrix + +| Stack | Gate | +|---|---| +| MVC + Tomcat | Stable | +| MVC + Jetty | compatibility | +| WebFlux + Reactor Netty | Stable profile | +| Nginx + selected server | production topology | +| MVC Virtual Thread | Advanced performance | + +--- + +## 31. Release 단계 + +### Phase A — HTTP Contract Foundation + +```text +modules +DTO/controller boundary +strict JSON +status/header +RFC 9457 +MVC Tomcat +WebFlux Reactor Netty +``` + +### Phase B — Contract Evolution + +```text +version +deprecation +route inventory +OpenAPI snapshot/diff +query catalog +signed cursor +``` + +### Phase C — Mutation Safety + +```text +ETag +If-Match +idempotency core +JPA evidence +Redis gate +response-loss fault +``` + +### Phase D — Durable Operation + +```text +operation store +lease +outbox +202 resource +restart/cancel/expire +``` + +### Phase E — Security·Proxy·Budget + +```text +actor/tenant +CORS/CSRF +trusted forwarded +Nginx +rate limit/admission +abuse +``` + +### Phase F — Operations + +```text +metrics +access log +audit +admin snapshot +performance +graceful shutdown +``` + +### Phase G — Advanced + +```text +virtual threads +patch +streaming +optional codecs +experimental contract lanes +``` + +--- + +## 32. 완료 정의 + +Stable 완료는 다음을 모두 증명해야 한다. + +```text +Controller가 Use Case만 호출 +Entity/Document wire 노출 없음 +HTTP status/header 의미 일치 +Problem status/body 일치 +400/422·409/412 구분 +cursor tamper 차단 +If-Match lost-update 방지 +same-key request dedupe +DB mutation + idempotency evidence atomic +commit response-loss side effect 1회 +durable acceptance 뒤에만 202 +trusted proxy only +CORS/CSRF policy +bounded budget/admission +OpenAPI and route inventory match +low-cardinality observability +actual Tomcat/Jetty/Reactor Netty/Nginx pass +abuse/performance/shutdown pass +``` + +--- + +## 33. 요구사항 추적 + +| 리서치 요구 | 설계 위치 | +|---|---| +| 기술선·MVC/WebFlux | 3, 21 | +| 책임 경계 | 4 | +| W1~W4 | 5 | +| 모듈 | 6 | +| 실행 증거 | 7, 18 | +| URI·method | 9 | +| status·header | 10 | +| binding·validation | 11 | +| controller 경계 | 12 | +| RFC 9457 | 13 | +| version/deprecation | 14 | +| pagination/filter/sort | 15 | +| conditional | 16 | +| idempotency | 17 | +| response loss | 18 | +| 202 operation | 19 | +| cache | 20 | +| streaming | 22 | +| security | 23 | +| proxy | 24 | +| budget/rate/admission | 25 | +| execution order | 26 | +| OpenAPI | 27 | +| observability | 28 | +| tests/release | 30~32 | + diff --git a/docs/web-superpowers-package/research/source-web-deep-research.md b/docs/web-superpowers-package/research/source-web-deep-research.md new file mode 100644 index 00000000..a270ca2b --- /dev/null +++ b/docs/web-superpowers-package/research/source-web-deep-research.md @@ -0,0 +1,1597 @@ +# 인바운드 HTTP API 실행 플랫폼 `web` 심층 리서치 + +본 조사에서 `web`은 Spring MVC 설정 묶음이나 공통 `ControllerAdvice`가 아니라, **HTTP 요청이 신뢰 경계를 통과해 Application Use Case에 진입하고, 그 실행 결과가 HTTP 의미론으로 다시 외부에 노출되는 전 과정을 통제하는 플랫폼**으로 정의하는 것이 적절합니다. + +특히 플랫폼이 보장해야 할 것은 “요청을 받았다”는 사실이 아니라 다음 증거의 구분입니다. + +```text +HTTP_RECEIVED +→ REQUEST_NORMALIZED +→ ROUTE_SELECTED +→ REQUEST_BOUND +→ REQUEST_VALIDATED +→ REQUEST_ADMITTED +→ APPLICATION_STARTED +→ APPLICATION_COMMITTED +→ RESPONSE_HEADERS_COMMITTED +→ RESPONSE_PARTIALLY_WRITTEN +→ RESPONSE_WRITE_COMPLETED_LOCALLY +→ CLIENT_OBSERVATION_UNKNOWN +``` + +마지막 단계가 중요합니다. HTTP 서버가 소켓에 응답을 성공적으로 썼다는 사실만으로 **클라이언트가 최종 응답을 관찰했다는 사실까지 증명할 수는 없습니다.** 따라서 상태 변경의 재호출 안전성은 네트워크 응답 성공 여부가 아니라 **Application Commit과 Idempotency/Reconciliation 증거**에서 확보해야 합니다. RFC 9110도 멱등이 아닌 요청은 원래 요청이 적용되지 않았음을 알 수 있거나 해당 작업 자체가 멱등임을 아는 경우가 아니라면 자동 재시도를 해서는 안 된다고 규정합니다. citeturn14view0 + +## 플랫폼 기준선과 책임 경계 + +### 기술 기준선 + +2026년 8월 기준으로 Spring Boot 4.1.0은 Java 17 이상을 요구하고 Spring Framework 7.0.8 이상을 사용하며, Servlet 컨테이너 기준으로 Tomcat 11.0.x와 Jetty 12.1.x를 지원합니다. 따라서 Backend Skeleton의 **Java 21 + Spring Boot 4.1 BOM** 기준은 타당합니다. Java 21은 Boot 최소 요구사항보다 높은 플랫폼 정책으로 두는 것이 좋습니다. citeturn2search3 + +Spring Boot는 Servlet 기반 Spring MVC와 Reactive Spring WebFlux를 모두 지원합니다. `spring-boot-starter-web`과 `spring-boot-starter-webflux`가 함께 존재하면 Boot는 기본적으로 MVC 애플리케이션으로 구성하므로, “둘 다 의존성에 넣고 런타임에서 알아서 고른다”는 구조는 피해야 합니다. citeturn3search0 + +권장 기준선은 다음과 같습니다. + +| 영역 | Stable 기준 | 판단 | +|---|---|---| +| Java | 21 | 플랫폼 최소선 | +| Spring Boot | 4.1.0 BOM | 전체 Spring 버전의 Source of Truth | +| Spring Framework | Boot 관리 7.0.x | 별도 버전 override 금지 | +| MVC | 기본 Stable | 일반 업무 API | +| MVC Server | Tomcat 우선, Jetty 호환 Lane | 둘 다 실제 서버 시험 | +| WebFlux | 별도 Stable 선택 Profile | 완전 Reactive 서비스 중심 | +| WebFlux Server | Reactor Netty 우선 | 별도 Netty 통합시험 | +| Virtual Thread MVC | Advanced | Java 21+, 부하 시험 통과 후 서비스별 사용 | +| HTTP Semantics | RFC 9110·9111 | Status, Method, Conditional, Cache | +| Error | RFC 9457 | `ProblemDetail` 기반 | +| OpenAPI Stable | **3.1.2 권고** | 3.2 tooling maturity 때문 | +| OpenAPI 3.2 | Experimental compatibility lane | Streaming 표현 검증 | +| 테스트 | Mock + 실제 Server + Nginx | Mock-only Stable 선언 금지 | + +Spring Boot는 Java 21 이상에서 `spring.threads.virtual.enabled=true`를 사용해 virtual-thread 기반 task execution을 구성할 수 있습니다. 다만 MVC의 blocking 모델을 virtual thread로 바꾼다고 데이터베이스 풀, 외부 API 동시성, 메모리, admission 한계까지 사라지는 것은 아니므로 별도 Profile로 취급하는 편이 안전합니다. citeturn2search1 + +MVC 비동기 응답에 대해서도 주의가 필요합니다. Spring MVC는 `Callable`, `DeferredResult`, `WebAsyncTask`, `SseEmitter`, reactive return type 등을 지원하지만 Servlet response write 자체는 blocking이며, Spring 문서는 streaming write용 기본 `AsyncTaskExecutor`가 부하 환경에 적합하지 않다고 명시합니다. citeturn1search0 + +따라서 실행 모델은 다음처럼 선언하는 것이 가장 명확합니다. + +```text +PLATFORM_THREAD_MVC + → W1 기본 + +VIRTUAL_THREAD_MVC + → W3 Advanced + → blocking dependency가 많은 서비스의 선택지 + +REACTIVE_WEBFLUX + → W3 Stable 선택 + → reactive DB / reactive HTTP / streaming chain + +WEBFLUX + blocking JPA + → Event Loop 직접 호출 금지 + → 명시적 blocking-offload profile 없이는 금지 +``` + +WebFlux 자체는 non-blocking I/O와 Reactive Streams backpressure를 중심으로 설계되어 있습니다. Framework 7은 blocking controller execution을 별도 executor로 넘기는 기능도 제공하므로 “WebFlux에서 JPA는 절대 기술적으로 불가능하다”기보다는 **Stable WebFlux Profile에서는 event-loop blocking을 금지하고, blocking bridge를 별도 Advanced 기능으로 취급하는 것**이 더 정확합니다. citeturn1search1turn8search1 + +### 기존 모듈과의 경계 + +`web`이 소유해야 하는 것은 **HTTP 표현과 인바운드 실행 정책**입니다. 트랜잭션, 저장소, 메시지 durability, 인증 원천 등을 흡수하면 다시 거대한 공통 모듈이 됩니다. + +| 연계 모듈 | `web` 소유 | 상대 모듈 소유 | +|---|---|---| +| `security` | 인증 결과를 Actor/Tenant HTTP Context로 연결, CORS·CSRF integration | Token 검증, 세션, Role·Permission 원천 | +| `httpclient` | inbound request | outbound HTTP, retry, circuit breaker | +| `graphql` | HTTP endpoint 입구·공통 security integration | GraphQL parsing, schema, resolver, GraphQL error | +| `grpc` | 일반 HTTP resource API | Protobuf RPC | +| `websocket` | Upgrade까지의 HTTP security/context | connection/session/message protocol | +| `fileserver` | metadata·ticket·reference | binary upload/download, Range | +| `jpa`·`mongodb` | Use Case 호출 | transaction, query, lock | +| `redis` | rate limit/idempotency SPI 호출 | atomic counter, TTL, failure semantics | +| `messaging` | `202`, Operation/Command 접수 표현 | durable enqueue, ACK, retry, replay, DLQ | +| `notification` | notification command 접수 HTTP 표현 | 실제 delivery lifecycle | + +특히 `Controller → JpaRepository`, `Controller → MongoTemplate`, `Controller → WebClient retry`, `Controller → Kafka publish/ACK`, `Controller → MinIO SDK`, Controller-level 업무 `@Transactional`은 기본 금지 대상으로 두는 것이 좋습니다. Spring Security 역시 service layer에 method security를 적용할 수 있으므로 Route authorization과 실제 Use Case·Object authorization을 분리할 수 있습니다. citeturn9search3 + +권장 호출 구조는 다음과 같습니다. + +```text +HTTP + ↓ +Web Edge Policy + ↓ +Controller Adapter + ↓ +Request DTO + ↓ +Application Command / Query + ↓ +Application Use Case + ↓ +JPA / MongoDB / HTTP Client / Messaging / Object Storage + ↓ +Application Result + Commit Evidence + ↓ +Response DTO / Operation Resource + ↓ +HTTP Status + Header + Representation +``` + +### 공개 계층과 모듈 구조 + +기능 등급은 제안된 W1~W4가 적절합니다. + +| 계층 | 기본 공개 대상 | 기능 | +|---|---|---| +| **W1 Standard HTTP API** | 모든 서비스 | Controller, DTO, validation, Problem Details, URI/status/header, pagination, versioning, OpenAPI | +| **W2 Advanced HTTP API** | 상태 변경·대용량 목록·stream | Idempotency, conditional mutation, async operation, SSE/NDJSON, admission | +| **W3 Transport Extension** | 특수 workload | WebFlux, virtual threads, functional endpoint, container-specific tuning | +| **W4 Contract/Admin Plane** | 플랫폼·운영 | OpenAPI diff, route inventory, deprecation usage, admin/debug endpoints | + +모듈 구조도 제안한 방향이 적합하되, **Redis/JPA와 직접 결합하는 구현은 `web-idempotency` 내부에 넣지 않는 것**을 권고합니다. + +```text +modules/web/ +├── web-core-api +├── web-contract +├── web-validation +├── web-error +├── web-pagination +├── web-idempotency // SPI + HTTP policy +├── web-versioning +├── web-security-integration +├── web-observability +├── web-openapi +├── web-mvc +├── web-webflux +├── web-streaming +├── web-admin +├── web-spring-boot-starter-mvc +├── web-spring-boot-starter-webflux +├── web-testkit-core +├── web-testkit-mvc +├── web-testkit-webflux +└── web-testkit-contract +``` + +의존성 방향은 다음처럼 제한하는 것이 좋습니다. + +```text +web-core-api + ↑ +web-contract / error / pagination / versioning / idempotency + ↑ + ┌───────────────┬────────────────┐ +web-mvc web-webflux web-openapi + ↑ ↑ +starter-mvc starter-webflux +``` + +`web-core-api`에는 `HttpServletRequest`, `ServerWebExchange`, Reactor `Mono/Flux`를 넣지 않습니다. `web-idempotency`는 `IdempotencyStore` 같은 capability interface만 정의하고 JPA/Redis adapter는 integration 계층에서 제공합니다. 그래야 Web → Persistence 역결합이 생기지 않습니다. + +## HTTP 계약과 외부 API 진화 규칙 + +### URI와 Method + +기본 URI 형식은 다음이 적절합니다. + +```text +/api/v1/documents/{documentId} +``` + +ID는 외부 식별자이며 DB PK, 파일 경로, 내부 저장 형태를 보장하지 않아야 합니다. + +Spring Framework 7에서는 과거 MVC의 암묵적 trailing-slash matching이 제거됐고 별도의 URL normalization 기능을 사용하도록 방향이 바뀌었습니다. 따라서 `/documents`와 `/documents/`를 우연히 동일하게 취급하지 말고 **canonical URI를 하나로 정해 redirect 또는 reject 정책을 명시**해야 합니다. citeturn10search0 + +권장 URI 규칙은 다음과 같습니다. + +| 항목 | Stable 정책 | +|---|---| +| Case | path는 case-sensitive | +| Trailing slash | canonical form 하나만 | +| Duplicate slash | 자동 합치기보다 reject/edge normalize | +| `%2F` encoded slash | ID 내부에서 기본 금지 | +| Matrix parameter | 기본 비지원 | +| Query order | 의미 없음. Cursor 등 일부 opaque value만 예외 | +| Identifier | opaque string | +| DB column/field name 노출 | 금지 | +| external redirect target | raw user URL 사용 금지 | + +Method는 HTTP 정의에 맞춰 사용해야 합니다. RFC 9110에서 GET·HEAD 같은 safe method와 PUT·DELETE 같은 idempotent method는 다른 개념이며, idempotent method라고 해도 구현이 비멱등 부수효과를 추가하면 실제 재호출 안전성은 깨집니다. citeturn14view0 + +| Method | 플랫폼 용도 | 등급 | +|---|---|---| +| GET | resource/query | Stable | +| HEAD | GET metadata | Stable | +| POST | create/command | Stable, mutation idempotency profile 필요 | +| PUT | 전체 교체/create-at-known-URI | Stable | +| PATCH | 부분 갱신 | Stable typed DTO, RFC patch formats Advanced | +| DELETE | 삭제 | Stable | +| OPTIONS | CORS/Allow | Stable | +| TRACE | 일반 API 차단 | 비지원 | +| CONNECT | 일반 API 차단 | 비지원 | +| custom method | 별도 승인 | Experimental | + +### PATCH 정책 + +PATCH 자체는 RFC 5789가 정의하고 있으며, PATCH는 기본적으로 safe도 idempotent도 아닙니다. RFC는 concurrent patch 충돌 위험 때문에 조건부 요청 사용을 권고합니다. citeturn17search1 + +JSON Patch는 RFC 6902의 `application/json-patch+json`, JSON Merge Patch는 **RFC 7396**이 현재 규격이며 RFC 7386을 명시적으로 obsolete합니다. 따라서 신규 문서에서 Merge Patch 기준을 RFC 7386으로 고정하지 말고 RFC 7396으로 업데이트해야 합니다. citeturn17search0turn21view0 + +권장 등급은 다음과 같습니다. + +| 방식 | 장점 | 주요 위험 | 권고 | +|---|---|---|---| +| Typed Update DTO | validation·권한·OpenAPI 명확 | DTO 증가 | **Stable 기본** | +| JSON Merge Patch | null/delete 표현 간결 | object/array 세밀 제어 약함 | Advanced | +| JSON Patch | add/remove/replace/test 등 정밀 | pointer 권한, 순서, 배열 복잡성 | Advanced | +| `Map` | 구현 간단 | mass assignment, validation·schema 붕괴 | 비지원 | + +PATCH mutation은 가능한 한 `If-Match`와 함께 사용해야 합니다. + +### Status와 Header 계약 + +RFC 9110은 HTTP 상태와 조건부 요청의 핵심 의미를 정의합니다. 201은 새 resource 생성을 나타내며 생성된 primary resource URI를 `Location`으로 반환하는 것이 일반적인 계약입니다. 202는 요청이 처리되도록 받아들여졌지만 처리가 완료되지 않았다는 뜻이고, 204와 304에는 response content가 없습니다. citeturn14view1turn15view0turn15view1turn15view2 + +권장 성공 계약은 다음과 같습니다. + +| 상황 | Status | Header/Body | +|---|---:|---| +| Resource 조회 | 200 | DTO + ETag 가능 | +| Resource 생성 | 201 | `Location` + representation 권장 | +| 비동기 durable 작업 접수 | 202 | `Location: /operations/{id}` | +| 성공, 반환 representation 없음 | 204 | Body 절대 없음 | +| Conditional GET not modified | 304 | Body 없음 | +| Range response | 206 | 일반 JSON API가 아닌 fileserver profile 중심 | + +오류·조건 상태는 다음처럼 고정하는 것이 좋습니다. + +| 상황 | Status | +|---|---:| +| malformed JSON·잘못된 scalar 형식 | 400 | +| 인증 없음/무효 | 401 | +| 인증됐으나 권한 없음 | 403 | +| 존재 은닉 보안 정책 | 404 | +| resource 없음 | 404 | +| method 불허 | 405 | +| Accept 불지원 | 406 | +| 영구 삭제된 resource를 의도적으로 모델링 | 410 | +| body limit 초과 | 413 | +| media type 불지원 | 415 | +| syntactically valid 후 transport semantic validation 실패 | **422** | +| business state conflict | 409 | +| HTTP precondition 불충족 | **412** | +| rate/quota 초과 | 429 | +| global admission/capacity 불가 | 503 | +| gateway/upstream 잘못된 응답 | 502 | +| gateway/upstream timeout | 504 | +| 예상하지 못한 내부 실패 | 500 | + +특히 `400 vs 422`는 프로젝트마다 흔히 흔들리는 영역입니다. RFC 9110의 422는 Content-Type과 syntax 자체는 이해했지만 포함된 instructions를 처리할 수 없는 경우를 뜻하며, RFC 9457도 validation error 예시에 422를 사용합니다. 따라서 **JSON parse/type binding 실패는 400, 정상 parse 후 Bean/transport validation 실패는 422**로 고정하는 것이 일관적입니다. citeturn14view2turn20view0 + +`409 vs 412`는 더 명확하게 구분해야 합니다. + +```text +If-Match / If-Unmodified-Since 등 HTTP precondition 실패 +→ 412 Precondition Failed + +HTTP conditional header와 무관한 도메인 상태 충돌 +→ 409 Conflict +``` + +RFC 9110의 `If-Match`는 특히 state-changing method에서 lost update 방지를 위해 사용되며 조건이 false이면 412가 핵심 응답입니다. citeturn14view3 + +핵심 Header 정책은 다음과 같습니다. + +| Header | 정책 | +|---|---| +| `Location` | 201 resource, 202 operation | +| `Content-Location` | 반환 representation의 식별 위치가 필요한 경우만 | +| `ETag` | cache/concurrency validator | +| `If-Match` | mutation optimistic concurrency | +| `If-None-Match` | GET cache, create-only | +| `Last-Modified` | 시간 기반 validator가 충분한 resource | +| `Cache-Control` | 모든 민감·cacheable endpoint 정책 명시 | +| `Vary` | representation이 실제로 달라지는 request header만 | +| `Retry-After` | 429·503/temporary admission profile | +| `Deprecation` | deprecated API | +| `Sunset` | endpoint 종료 예정 | +| `Link` | deprecation docs, operation 관계 등 | +| `Allow` | 405/OPTIONS | +| `Content-Language` | localized representation | +| `Content-Type` | 명시 | + +### Request Binding·Codec·Validation + +요청은 다음 세 계층으로 분리해야 합니다. + +```text +HTTP Parsing +→ URI/Header/JSON syntax와 scalar conversion + +Transport Validation +→ DTO 길이·범위·형식·개수·cross-field + +Application Validation +→ 존재 여부·업무 상태·권한·도메인 invariant +``` + +OWASP API Security Top 10은 object-level authorization, object-property-level authorization 및 resource consumption을 핵심 API 위험으로 다룹니다. 따라서 Entity 직접 binding과 unrestricted field update는 Web Platform 수준에서 차단하는 편이 적절합니다. citeturn13search1turn13search0turn13search2 + +기본 JSON Request Profile은 다음을 권고합니다. + +| 입력 특성 | 권장 정책 | +|---|---| +| Unknown property | mutation request에서 기본 reject | +| Duplicate JSON key | reject | +| trailing token | reject | +| unknown enum | reject | +| enum case | case-sensitive | +| polymorphic deserialization | allowlist discriminator 없이는 금지 | +| empty string → null coercion | 기본 금지 | +| number → string coercion | 기본 금지 | +| string → number coercion | 명시적 converter 없이는 금지 | +| JSON depth | hard limit | +| array elements | hard limit | +| string bytes | transport absolute limit + field validation | +| collection element | `@Valid`/element constraint | +| nested object | nested validation | +| cross-field | DTO-level validator | +| DB lookup | transport validator에서 금지 | + +`@RequestBody UserEntity` 같은 binding은 mass assignment와 persistence representation 노출을 동시에 일으키므로 다음처럼 request-specific DTO를 사용해야 합니다. + +```java +CreateUserRequest +UpdateUserRequest +UserResponse +``` + +Wire type도 중앙 Manifest로 고정합니다. + +| Java 의미 | HTTP JSON 표현 권고 | +|---|---| +| `Instant` | RFC 3339/ISO-8601 UTC `Z` 문자열 | +| `OffsetDateTime` | offset 포함 ISO 문자열 | +| `LocalDate` | `YYYY-MM-DD` | +| `Duration` | ISO-8601 duration | +| UUID | canonical string | +| BigDecimal | schema에 scale/precision 명시 | +| `long` | JS safe range 초과 가능 시 string wire profile | +| Enum | Java enum name과 분리 가능한 stable wire name | +| URI | string + URI format | +| Locale | BCP 47 language tag | + +### Response와 Problem Details + +모든 성공 response를 `ApiResponse`에 넣는 방식보다는 다음을 권장합니다. + +```text +성공 +→ Resource/Collection/Operation DTO + +오류 +→ RFC 9457 Problem Details +``` + +RFC 9457은 `application/problem+json`, `type`, `title`, `status`, `detail`, `instance`와 problem-specific extension을 정의하며, 클라이언트는 알 수 없는 extension을 무시해야 합니다. citeturn20view0turn20view2 + +Spring Framework 7의 MVC와 WebFlux는 `ProblemDetail`, `ErrorResponse`, `ErrorResponseException` 및 MVC의 `ResponseEntityExceptionHandler` 등 RFC 9457 지원을 제공합니다. `ProblemDetail.status`는 실제 응답 상태 결정에도 사용되고, Spring은 Problem Detail에 `application/problem+json`을 선호되는 representation으로 제공합니다. citeturn20view3turn8search3 + +권장 Problem Catalog는 다음과 같습니다. + +| `code` | 기본 status | +|---|---:| +| `MALFORMED_REQUEST` | 400 | +| `BINDING_FAILED` | 400 | +| `VALIDATION_FAILED` | 422 | +| `AUTHENTICATION_REQUIRED` | 401 | +| `ACCESS_DENIED` | 403 | +| `RESOURCE_NOT_FOUND` | 404 | +| `RESOURCE_CONFLICT` | 409 | +| `PRECONDITION_FAILED` | 412 | +| `IDEMPOTENCY_KEY_REQUIRED` | 400 | +| `IDEMPOTENCY_KEY_REUSED` | 422 | +| `IDEMPOTENCY_REQUEST_IN_PROGRESS` | 409 | +| `RATE_LIMITED` | 429 | +| `ADMISSION_REJECTED` | 503 | +| `DEPENDENCY_FAILURE` | 502/503 | +| `DEPENDENCY_TIMEOUT` | 504 | +| `INTERNAL_ERROR` | 500 | + +Idempotency의 422/409 구분은 만료된 IETF draft이기는 하지만 상호운용성 참고 가치가 있습니다. 해당 draft는 동일 key에 다른 payload를 재사용하면 422, 원 요청이 아직 진행 중인 상태에서 같은 key가 오면 409를 제안합니다. citeturn22view0turn22view1 + +Problem payload는 다음처럼 제한합니다. + +```json +{ + "type": "https://hyeonworks.com/problems/validation", + "title": "Invalid request", + "status": 422, + "code": "VALIDATION_FAILED", + "instance": "/problems/01J...", + "traceId": "...", + "errors": [ + { + "pointer": "/title", + "code": "SIZE", + "message": "..." + } + ] +} +``` + +RFC 9457은 Problem Details를 내부 디버깅 도구로 사용하지 말라고 경고하며 stack dump나 구현 세부사항을 노출하지 않아야 한다고 명시합니다. 또한 body의 `status`와 실제 HTTP status가 불일치할 수 있는 위험을 특별히 지적합니다. 따라서 **stack trace, SQL, Mongo query, host, token, provider raw error, PII를 Problem Detail에 넣지 않고 실제 status/body status 일치 contract test를 필수화**해야 합니다. citeturn20view1 + +### API Versioning·Deprecation + +Spring Framework 7에는 MVC와 WebFlux 모두 API versioning 기능이 있으며 version을 request header, query parameter, path segment, media type parameter에서 선택할 수 있습니다. 또한 deprecation handler는 표준 `Deprecation`, `Sunset`, `Link` 응답을 지원합니다. citeturn8search0turn8search1 + +Backend Skeleton 기본은 다음을 권고합니다. + +```text +Major version +→ path +→ /api/v1 + +Minor/Patch +→ URL version 증가 없음 +→ additive compatible evolution +``` + +Path version이 좋은 이유는 Gateway, Nginx, OpenAPI snapshot, traffic inventory, access log에서 버전이 명시적으로 보이기 때문입니다. Header version은 내부 API처럼 URL 안정성이 특히 중요한 경우의 선택 Profile로 두는 것이 좋습니다. + +`Deprecation`은 2025년 RFC 9745로 표준화되었으며 deprecation date를 전달하고 관련 documentation을 `Link`로 연결할 수 있습니다. `Sunset`은 RFC 8594가 resource가 향후 이용 불가능해질 예상 시점을 알리는 header로 정의합니다. citeturn16search5turn16search1 + +따라서 API 제거 조건은 단순 날짜가 아니라 다음 gate를 모두 통과해야 합니다. + +```text +OpenAPI breaking diff ++ Deprecated route usage = 허용 기준 이하 ++ Sunset 기간 경과 ++ Client owner 확인 ++ consumer contract 통과 ++ rollback 가능 +``` + +## Collection·Concurrency·Idempotency·비동기 작업 + +### Pagination·Filter·Sort·Projection + +목록 API는 DB pagination API를 HTTP에 그대로 노출하면 안 됩니다. + +| 방식 | 적합한 용도 | 기본 등급 | +|---|---|---| +| Page | 관리자 화면, total count 필요 | Stable 제한형 | +| Slice | 일반 목록, 다음 페이지 존재 여부 | Stable | +| Keyset Cursor | 대규모·시간순·높은 insert rate | **Stable 권장** | +| raw offset/limit | 작은 데이터 | 제한적 | + +Cursor는 opaque token이어야 하며 최소 다음 의미를 포함하거나 서버 쪽 상태로 참조해야 합니다. + +```text +cursorVersion +queryProfile +sortValues +uniqueTieBreaker +filterFingerprint +issuedAt +integrity MAC +``` + +예를 들어 동일 `createdAt` 값을 가진 행이 여러 개 있을 수 있으므로 정렬은 다음처럼 반드시 total order가 되어야 합니다. + +```text +ORDER BY createdAt DESC, documentId DESC +``` + +API가 직접 허용할 query vocabulary를 관리해야 합니다. + +```text +SortFieldCatalog +FilterFieldCatalog +FilterOperatorCatalog +ProjectionProfile +IncludeProfile +``` + +따라서 다음 API는 금지합니다. + +```text +?sort=${databaseColumn} +?filter=${JPQL} +?filter=${Mongo BSON} +?include=* +?limit=2147483647 +``` + +초기 플랫폼 profile로는 예를 들어 `defaultLimit=50`, `hardMaxLimit=200` 정도에서 시작하되 서비스별 performance test로 조정하는 방식을 권고합니다. 숫자 자체보다 중요한 것은 **hard maximum이 존재하고 API 계약에 포함되는 것**입니다. OWASP도 records per page, execution timeout, upload size와 같은 resource limit이 API resource-consumption 방어의 일부라고 명시합니다. citeturn13search2 + +### Conditional Request와 Optimistic Concurrency + +HTTP validator는 application version과 연결할 수 있지만 HTTP와 DB를 동일 개념으로 만들 필요는 없습니다. + +```text +DB version / aggregate version + ↓ +ETag representation + ↓ +If-Match + ↓ +Application expectedVersion +``` + +Strong ETag를 mutation concurrency token으로 사용하는 것을 권장합니다. + +```http +GET /api/v1/documents/d1 +ETag: "v17" + +PATCH /api/v1/documents/d1 +If-Match: "v17" +``` + +version이 이미 `v18`이면: + +```text +412 Precondition Failed +``` + +`If-Match`는 strong comparison을 사용하며 state-changing method에서 lost-update 방지를 위한 대표적인 용도로 정의됩니다. citeturn14view3 + +Create-only PUT도 다음처럼 표현할 수 있습니다. + +```http +PUT /api/v1/documents/client-generated-id +If-None-Match: * +``` + +Conditional request의 목적과 Idempotency는 구분해야 합니다. + +```text +If-Match +→ "내가 읽은 버전이 아직 최신인가?" + +Idempotency-Key +→ "이 업무 명령을 이미 실행했는가?" +``` + +둘은 대체 관계가 아니라 동시에 필요한 경우가 많습니다. + +### Idempotency와 완료 불명확성 + +2026년 8월 현재 `Idempotency-Key`는 확정된 RFC가 아닙니다. 최신 공개된 `draft-ietf-httpapi-idempotency-key-header-07`은 2025년 10월 15일 발행됐고 2026년 4월 18일 만료되었습니다. 따라서 `Idempotency-Key`라는 이름은 충분히 실용적인 compatibility profile이지만 **“IETF HTTP 표준”이라고 문서화하면 안 됩니다.** citeturn22view3turn21view1 + +다만 draft가 정의한 핵심 모델은 플랫폼 설계에 유용합니다. client key와 server-generated fingerprint를 결합하고, 같은 key의 완료된 요청은 원래 결과를 replay하며, 다른 payload로 같은 key를 재사용하지 않는 모델입니다. citeturn22view2 + +권장 scope: + +```text +tenantId ++ actor/client identity ++ operationId or route contract ++ idempotencyKey +``` + +저장 정보: + +```text +key scope +request fingerprint +processing state +application result identity +response status +response header allowlist +response body or durable result reference +createdAt +completedAt +expiresAt +``` + +상태 모델은 사용자 제안보다 한 단계 더 세밀하게 두는 것이 좋습니다. + +| 상태 | 의미 | +|---|---| +| `ABSENT` | key 없음 | +| `PROCESSING` | 실행 소유권 확보 | +| `APPLICATION_COMMITTED` | 업무 commit 증거 있음 | +| `COMPLETED_REPLAYABLE` | HTTP 결과 replay 가능 | +| `FAILED_RETRYABLE` | 업무 commit 없다고 증명 가능 | +| `COMPLETION_UNKNOWN` | commit 여부 자체가 불명확 | +| `EXPIRED` | replay guarantee 기간 종료 | + +여기서 가장 중요한 구현 규칙이 있습니다. + +**Redis의 Idempotency record와 JPA business transaction이 서로 다른 atomic resource이면 Redis의 `COMPLETED` 플래그만으로 DB commit을 증명해서는 안 됩니다.** + +예를 들어: + +```text +DB COMMIT 성공 +→ 프로세스 crash +→ Redis COMPLETED 기록 실패 +``` + +가 발생하면 재시도가 mutation을 또 실행할 수 있습니다. + +따라서 DB-local mutation이라면 다음이 가장 강합니다. + +```text +Business mutation ++ Idempotency execution record += 같은 DB transaction에서 commit +``` + +Redis는 빠른 concurrent-request exclusion이나 read-through replay cache에 사용할 수 있지만, DB transaction의 **유일한 commit evidence**로 삼으려면 별도의 transactional protocol이 필요합니다. 외부 메시지나 다른 저장소까지 걸친 작업이라면 operation/outbox/reconciliation을 사용해야 합니다. + +### 요청 실행 증거와 Retry 판정 + +HTTP 플랫폼에서 핵심 질문에 답하기 위해 다음 evidence model을 명시적으로 두는 것을 권고합니다. + +| Evidence | Application 진입 | Commit | 클라이언트 Retry | +|---|---:|---:|---| +| `REQUEST_REJECTED` | 아니오 | 아니오 | 수정 또는 정책에 따라 | +| `REQUEST_NOT_EXECUTED` | 아니오라고 증명 | 아니오 | 안전 | +| `APPLICATION_STARTED` | 예 | 불명 | 멱등 증거 없으면 자동 재시도 금지 | +| `APPLICATION_ROLLED_BACK` | 예 | 아니오라고 증명 | 정책에 따라 가능 | +| `APPLICATION_COMMITTED` | 예 | 예 | 동일 command 재실행 금지, replay/reconcile | +| `RESPONSE_HEADERS_COMMITTED` | 예 | 보통 별도 evidence 필요 | HTTP status만 보고 판정 금지 | +| `PARTIAL_RESPONSE_DELIVERED` | 예 | 별도 판단 | stream resume contract 필요 | +| `CLIENT_COMPLETION_UNKNOWN` | 예 가능 | 예 가능 | idempotency/reconciliation 필요 | + +특히 다음 시나리오는 플랫폼의 필수 fault test가 되어야 합니다. + +```text +POST + ↓ +Application Transaction COMMIT + ↓ +HTTP response write 시작 + ↓ +TCP reset + ↓ +Client sees IOException +``` + +이 상황에서 서버가 “client가 받지 못했으므로 rollback”할 방법은 없습니다. 따라서 client 재시도를 안전하게 만드는 수단은 다음 중 하나입니다. + +```text +Idempotency Key +Client-generated resource ID +Conditional Create +Durable Operation Resource +Reconciliation GET +``` + +RFC 9110에는 `If-Match` 실패 시 서버가 해당 변경이 이미 이전 요청에서 성공했음을 검증할 수 있는 특정 경우 기존 성공을 인지할 수 있도록 하는 의미론도 있어, “응답 유실 후 재호출”이라는 문제가 HTTP 자체에서도 중요한 고려 대상임을 보여줍니다. citeturn14view3 + +### `202 Accepted`와 Operation Resource + +RFC 9110의 202는 processing이 완료되지 않았고 최종적으로 실행되지 않을 수도 있음을 뜻하며, HTTP 자체에는 나중에 비동기 결과를 다시 “push”하는 표준 기능이 없다고 명시합니다. citeturn15view0 + +따라서 다음 계약이 적절합니다. + +```http +POST /api/v1/exports +→ 202 Accepted +Location: /api/v1/operations/op_123 +Retry-After: 3 +``` + +```text +GET /api/v1/operations/op_123 + +PENDING +→ RUNNING +→ SUCCEEDED + ↘ FAILED + ↘ CANCELED +``` + +Operation DTO: + +```text +operationId +status +createdAt +startedAt +completedAt +progress +resultLocation +problem +retryAfter +expiresAt +``` + +**202는 “비동기 thread를 시작했다”는 의미가 아니라 “비동기 작업을 추적할 수 있는 방식으로 접수했다”는 플랫폼 계약**으로 강화하는 것이 좋습니다. + +따라서: + +```text +Controller +→ @Async 호출 +→ 202 +``` + +는 Stable 구현으로 인정하지 않습니다. Spring의 async execution은 `TaskExecutor` 기반 process-local execution abstraction이므로 durable queue나 crash recovery를 의미하지 않습니다. citeturn18search24 + +Stable 202 조건은 다음입니다. + +```text +Durable command/operation 저장 성공 +또는 +Business DB transaction + transactional outbox 성공 + +그 이후에만 +→ 202 +``` + +### HTTP Cache + +RFC 9111은 HTTP cache freshness, validation, `Cache-Control`, `Vary`, authenticated response caching, unsafe method 이후 invalidation을 정의합니다. unsafe method의 성공 응답은 통과한 cache에서 target URI를 무효화하지만 관련 모든 resource가 전역적으로 자동 무효화된다는 보장은 없습니다. citeturn19view1 + +권장 기본 정책: + +| 응답 종류 | 정책 | +|---|---| +| 개인정보·민감한 업무 상태 | `private, no-store` | +| public immutable resource | `public, max-age=..., immutable` | +| public mutable resource | `ETag` + freshness/conditional GET | +| 사용자별이지만 browser private cache 허용 | `private, max-age=...` 명시 | +| 인증 요청을 shared cache에 저장 | 명시적 검토 없이는 금지 | + +RFC 9111은 `Authorization`이 있는 요청에 대한 응답을 shared cache가 재사용하려면 이를 허용하는 explicit cache directive가 필요하다고 규정합니다. citeturn19view0 + +또 `no-cache`와 `no-store`는 구분해야 합니다. + +```text +no-cache +→ 저장 자체를 금지하지 않음 +→ 재사용 전에 validation 요구 + +no-store +→ request/response를 cache에 저장하지 말라는 의미 +``` + +RFC 9111이 이를 각각 별도 의미로 정의합니다. citeturn19view2 + +`Vary`는 response representation이 실제 어떤 request header에 따라 달라졌는지 cache key에 반영하는 수단입니다. `Accept`, `Accept-Language`, version header를 사용한다면 해당 차이가 실제 representation을 바꾸는 경우에만 추가해야 합니다. citeturn19view3 + +## 실행 스택·Streaming·보안·Proxy + +### MVC와 WebFlux Streaming + +Streaming은 성공 envelope나 일반 `ProblemDetail`과 다른 계약이 필요합니다. + +| 방식 | MVC | WebFlux | 등급 | +|---|---|---|---| +| Async single response | `Callable`, `DeferredResult`, `WebAsyncTask` | `Mono` | Stable | +| SSE | `SseEmitter` | `Flux>` | Advanced | +| NDJSON | emitter/streaming writer | `Flux` | Advanced | +| JSON Sequence | custom writer | reactive writer | Advanced | +| streamed giant JSON array | 가능 | 가능 | 기본 비권장 | +| Bidirectional | WebSocket module | WebSocket module | Web 범위 밖 | + +Spring MVC는 remote client disconnect를 항상 즉시 callback으로 알려주는 Servlet API가 없기 때문에 streaming response에서는 heartbeat/주기적 write를 통해 disconnect를 감지해야 합니다. 또한 MVC streaming write는 blocking thread를 사용합니다. citeturn1search0 + +응답이 아직 commit되지 않은 시점의 오류는 일반 `ProblemDetail`로 바꿀 수 있습니다. + +```text +Controller 실행 +→ 오류 +→ headers 미전송 +→ 4xx/5xx + application/problem+json +``` + +그러나 headers와 일부 stream frame이 이미 전송된 뒤라면 HTTP status를 200에서 500으로 바꿀 수 없습니다. + +```text +HTTP/1.1 200 OK +Content-Type: text/event-stream + +event: item +... + +[application error] +``` + +이 경우 계약은 transport별로 달라야 합니다. + +| Stream | Commit 후 오류 표현 | +|---|---| +| SSE | typed `event: error` 전송 후 close, 가능할 때 | +| NDJSON | terminal error record profile 또는 abrupt EOF | +| JSON Sequence | typed error record/terminal marker | +| plain streamed JSON | truncation을 정상 완료와 구분하기 어려워 장기 stream에서 비권장 | + +따라서 모든 stream protocol에는 **정상 종료 marker와 비정상 EOF의 차이**를 정의해야 합니다. + +Replay가 필요하면 Web이 자체 이벤트 DB를 만들지 않습니다. + +```text +SSE Last-Event-ID + ↓ +Web adapter + ↓ +Messaging/Event Log resume cursor +``` + +SSE의 `Last-Event-ID`는 resume hint일 뿐이며 durable history가 자동으로 생기는 것은 아닙니다. + +### CORS·CSRF·Authorization + +Spring Security는 CORS가 Security보다 먼저 처리되어야 한다고 명시합니다. 브라우저의 preflight request에는 일반적인 session cookie가 포함되지 않을 수 있기 때문입니다. citeturn9search0turn9search2 + +Production CORS는 다음처럼 allowlist profile로 구성해야 합니다. + +```text +allowed origins → exact allowlist +allowed methods → API별 allowlist +allowed headers → explicit set +exposed headers → 필요한 응답 Header만 +credentials → 명시적으로 필요한 API만 +preflight max-age → 정책값 +``` + +다음은 금지합니다. + +```text +Origin reflection +* + credentials +production 전체 경로 unrestricted CORS +``` + +CSRF는 “REST이므로 무조건 disable”이 아니라 credential transport에 따라 결정해야 합니다. Spring Security는 browser-based application에 CSRF protection을 제공하며 cookie/session repository 등 다양한 방식을 지원합니다. citeturn9search1 + +권장 분류: + +| Authentication | CSRF | +|---|---| +| Session Cookie | 필수 | +| BFF cookie | 필수 | +| Browser cookie + bearer 혼합 | cookie-authenticated mutation 보호 | +| Authorization Header only, cookie credential 없음 | threat model 검토 후 disable 가능 | +| non-browser service-to-service bearer | 일반적으로 CSRF 대상 아님 | + +Authorization은 다음 순서로 분리합니다. + +```text +Authentication +→ Route/Function Permission +→ Application Use Case Permission +→ Object Authorization +→ Property Authorization +→ Tenant Isolation +``` + +OWASP는 object-level authorization과 object-property-level authorization을 별개의 주요 API 위험으로 분류하기 때문에 `@PreAuthorize("hasRole('EDITOR')")`가 통과했다고 특정 `documentId` 수정 권한까지 증명된 것으로 취급하면 안 됩니다. citeturn13search1turn13search0 + +### Forwarded Header와 Nginx + +Spring의 `ForwardedHeaderFilter`는 `Forwarded` 및 `X-Forwarded-*` 정보를 바탕으로 scheme, host, port 등을 외부 요청 기준으로 변환할 수 있지만, Spring 공식 문서는 application이 header가 악의적 client에서 왔는지 trusted proxy에서 왔는지 자체적으로 알 수 없기 때문에 **신뢰 경계의 proxy가 외부 Forwarded header를 제거하고 자신이 설정해야 한다**고 명시합니다. citeturn10search0 + +따라서 Host Nginx 구조는 다음처럼 고정하는 것이 좋습니다. + +```text +Internet Client + ↓ +Nginx + ├─ incoming Forwarded / X-Forwarded-* 제거 + ├─ authoritative client address 계산 + ├─ Forwarded 또는 X-Forwarded-* 재설정 + └─ trusted internal connection + ↓ +Spring Boot + ↓ +한 가지 Forwarded processing strategy만 적용 +``` + +Spring Boot는 forwarded header 처리를 위한 전략을 제공하므로 `NONE`, container-native 방식, Spring Framework 기반 방식을 topology에 맞게 하나만 선택해야 합니다. citeturn12search0turn12search1turn12search17 + +동현님이 언급한 `/api`, `/dev-api`, `X-Forwarded-Prefix`까지 고려하면 **trusted Nginx 환경에서는 Framework-based normalization을 우선 검증하고, direct-access profile에서는 `NONE`**을 두는 접근이 좋습니다. + +외부 URL 계산도 다음처럼 해야 합니다. + +```text +raw Host +raw X-Forwarded-Host + ↓ 직접 사용 금지 + +trusted proxy normalization + ↓ +NormalizedExternalRequestContext + ↓ +Location / redirect / absolute Link +``` + +고위험 URL, 예를 들어 password reset이나 callback 등은 가능하면 request host를 재조립하지 말고 configured external origin을 사용하는 편이 더 안전합니다. + +시험해야 할 공격: + +```text +Host injection +X-Forwarded-Host spoof +X-Forwarded-For spoof +scheme spoof +port spoof +prefix duplication +encoded path confusion +open redirect +``` + +### Resource Budget·Rate Limit·Admission + +Web Platform은 다음 resource를 무한대로 허용해서는 안 됩니다. OWASP도 CPU, memory, bandwidth, upload size, request rate, records per page, third-party cost 제한 부족을 API resource consumption 위험으로 분류합니다. citeturn13search2 + +플랫폼 초기 baseline 예시는 다음 정도가 합리적인 출발점입니다. 이는 RFC 기본값이 아니라 **조직 정책값**이며 Nginx·Tomcat·Jetty·Netty 및 실제 payload 통계로 조정해야 합니다. + +| Budget | Standard 초기값 예 | +|---|---:| +| URI | 8 KiB | +| 전체 request headers | 16 KiB | +| query parameter count | 100 | +| standard JSON body | 1 MiB | +| large JSON profile | 8 MiB 이하 별도 승인 | +| JSON nesting depth | 64 | +| generic array elements | 1,000 | +| multipart part count | 20 | +| sync request hard duration | 30 s 이하, route deadline은 더 짧게 | +| standard response | 수 MiB 내 | +| streaming buffer | bounded, connection별 별도 profile | + +Nginx limit보다 애플리케이션이 훨씬 큰 값을 갖거나 반대가 되면 어느 계층에서 413/timeout이 발생하는지 예측할 수 없으므로 다음 값을 함께 관리해야 합니다. + +```text +Nginx + body/header/timeout/idle + +Spring Boot server + header/body/form limits + +JSON codec + depth/string/number + +Application + collection/filter/page limits +``` + +Rate limit은 Web에서 HTTP 표현을, Redis 등이 원자적 quota capability를 담당합니다. + +```text +RateLimiter capability +→ decision +→ Web mapping +→ 429 + ProblemDetail + Retry-After +``` + +2026년 8월 현재 새 `RateLimit`/`RateLimit-Policy` field 사양도 아직 RFC가 아니라 2026년 5월 23일 발행된 Internet-Draft `-11` 상태이며 2026년 11월 24일 만료 예정입니다. 따라서 **429와 `Retry-After`는 Stable**, 새 RateLimit fields는 호환/Experimental profile로 두는 것이 안전합니다. citeturn21view2 + +Admission은 rate limit과 별개입니다. + +```text +Rate Limit +→ 일정 기간 사용량 제어 + +Admission +→ 지금 실행할 capacity가 있는가? +``` + +권장 구분: + +| 상황 | 응답 | +|---|---| +| 특정 Actor/IP/Tenant quota | 429 | +| route quota | 429 | +| 전체 write concurrency 포화 | 503 | +| expensive query concurrency 포화 | 503 | +| bounded queue timeout | 503 + Retry-After 가능 | + +무한 queue는 latency를 숨기므로 `maxConcurrent + small bounded queue + queueTimeout` 방식이 적절합니다. + +### Filter·Interceptor·Advice 실행 모델 + +제안된 논리 순서는 대체로 맞지만, **Spring의 실제 실행 단계에서 Route 선택 전에는 route-specific admission이나 method-specific authorization을 완전히 판단할 수 없다는 점**을 반영해야 합니다. + +권장 의미 계층은 다음입니다. + +```text +Edge Phase + Forwarded normalization + → CORS + → trace/request-id + → authentication + → global request budget/admission + +Routing Phase + → route mapping + → API version resolution + +Handler Policy Phase + → route-specific authorization + → route-specific admission/rate limit + → idempotency requirement + → precondition policy + +Binding Phase + → decode + → bind + → transport validation + +Application Phase + → controller adapter + → use case + → object/tenant authorization + → transaction + +Outbound Phase + → response mapping + → cache/ETag + → Problem Details if response uncommitted + → metric/access log +``` + +MVC에서는 Servlet `Filter`, Spring Security chain, `HandlerInterceptor`, argument resolver, ControllerAdvice 등이 이 논리 계층에 대응합니다. Async MVC는 최초 request thread에서 반환된 후 `ASYNC` redispatch가 발생할 수 있으므로 filter의 dispatcher type과 “한 요청당 한 번” 관측 정책을 반드시 통합시험해야 합니다. Spring 문서도 async dispatch가 별도의 Servlet dispatch임을 설명합니다. citeturn1search0 + +또한 request body를 logging/idempotency filter가 먼저 읽어 소비하는 구조는 피해야 합니다. + +```text +Bad: +Filter +→ read entire InputStream +→ String +→ hash/log +→ Controller + +Preferred: +bounded codec/binder +→ typed request +→ canonical operation fingerprint +→ idempotency gateway +→ use case +``` + +Idempotency fingerprint를 raw JSON byte 단위로 할지 semantic DTO 단위로 할지도 계약으로 고정해야 합니다. 일반 업무 API에는 **route + normalized path inputs + semantic command DTO의 deterministic fingerprint**가 더 다루기 쉽습니다. JSON whitespace나 object field order 차이 때문에 동일 업무 요청이 다른 fingerprint가 되는 문제를 줄일 수 있기 때문입니다. + +## OpenAPI·계약 관리·관측성 + +### OpenAPI 기준선 + +OpenAPI의 최신 공개 규격은 3.2.0이며 2025년 9월 19일 발표되었습니다. 3.2에는 sequential media type의 streaming을 더 정확하게 표현하기 위한 `itemSchema`가 도입되어 SSE, NDJSON/JSON sequence 성격의 API에 특히 유용합니다. citeturn18search2 + +하지만 **Stable 산출물을 바로 3.2.0으로 올리는 것은 아직 권고하지 않습니다.** + +현재 springdoc-openapi 문서는 Spring Boot 4를 지원하는 3.x 계열과 2026년 7월 기준 stable 3.1.0을 안내하고 있지만 지원 범위를 일반적으로 “OpenAPI 3”이라고 표현합니다. springdoc 자체도 Spring Framework 팀이 유지하는 공식 Spring 프로젝트가 아니라 community project입니다. citeturn17search2turn17search4turn21view3 + +OpenAPI Generator의 2026년 7월 stable release는 7.24.0이지만 공식 README는 입력 지원을 여전히 OpenAPI 2.0과 3.0 계열로 포괄적으로 표현하고 있고, 2026년 1월 제기된 “OpenAPI 3.2 지원” issue도 별도 enhancement 요청 상태입니다. citeturn18search13turn18search4turn18search0 + +따라서 권장 정책은 다음입니다. + +```text +Stable artifact +→ OpenAPI 3.1.2 + +Compatibility lane +→ OpenAPI 3.2.0 + +3.2 승격 조건 +→ springdoc output +→ Swagger/Scalar render +→ linter +→ diff tool +→ selected Java/TS generators +→ generated client compile +→ SSE/NDJSON schema +모두 통과 +``` + +즉, **“최신 스펙”과 “조직 Stable 계약 format”을 분리**합니다. + +### Contract-first vs Code-first + +Web 플랫폼에는 Hybrid 방식이 가장 적절합니다. + +```text +Controller + DTO + Validation + ↓ +Runtime generated OpenAPI + ↓ +Normalize + ↓ +Approved Snapshot + ↓ +CI Lint / Breaking Diff + ↓ +Client generation +``` + +그 결과 runtime annotation이 API contract의 유일한 source가 되지도 않고, 반대로 구현과 분리된 YAML이 계속 drift하는 것도 방지할 수 있습니다. + +Release Gate: + +| Gate | 실패 조건 | +|---|---| +| Route inventory | 문서에 없는 public route | +| OpenAPI generation | runtime 생성 실패 | +| OpenAPI lint | 조직 규칙 위반 | +| breaking diff | 비승인 breaking change | +| Problem schema | catalog 불일치 | +| generated client | compile 실패 | +| consumer contract | 기존 client scenario 실패 | +| deprecated API | owner/usage gate 불충족 | + +OpenAPI 문서에서는 반드시 다음을 표현해야 합니다. + +```text +success statuses +Problem Details +validation constraints +security scheme +pagination cursor +ETag / If-Match +Idempotency-Key profile +Deprecation +202 Operation Resource +content negotiation +SSE/NDJSON profile +``` + +Swagger UI/Scalar는 운영 기능이지 public API contract 자체가 아닙니다. + +| 환경 | 권고 | +|---|---| +| Local | 허용 | +| Test | 허용 | +| Dev | 인증 후 | +| Staging | 관리자 | +| Prod | 기본 비활성 또는 admin plane | + +springdoc은 MVC와 WebFlux 각각의 starter를 제공하므로 Web Platform에서도 각각 독립 의존성을 가져야 합니다. citeturn21view3 + +### Metric·Trace·Access Log + +Spring Boot는 MVC와 WebFlux의 HTTP server request를 자동 계측하며 기본 metric 이름으로 `http.server.requests`를 사용합니다. Spring Boot observability는 Micrometer Observation을 metrics와 traces의 공통 abstraction으로 사용합니다. 따라서 플랫폼이 HTTP 계측 전체를 재구현하는 대신 **tag vocabulary와 cardinality, problem/idempotency/admission 관련 custom observation만 추가**하는 편이 좋습니다. citeturn16search3turn16search7 + +Spring Framework도 server request observation convention을 customization할 수 있습니다. citeturn18search3 + +권장 metric dimension: + +```text +method +routeTemplate +status +outcome +apiVersion +operationName +problemCode +clientProfile +``` + +금지: + +```text +raw URL +query string +userId +tenantId raw +resourceId +Idempotency-Key +access token +cookie +request body +``` + +추가 플랫폼 metric: + +| Metric | 의미 | +|---|---| +| active requests | 현재 실행 | +| request/response bytes | payload | +| validation failures | transport quality | +| problem count | typed error | +| admission rejects | overload | +| rate-limit rejects | quota | +| idempotency new/replay/conflict | mutation safety | +| operation accepted/completed | async lifecycle | +| active streams | streaming pressure | +| stream duration | connection age | +| slow consumers | streaming bottleneck | +| client disconnect | partial delivery | + +Access log는 metrics보다 높은 cardinality를 허용할 수 있지만 secret/PII를 넣지 않습니다. + +```text +timestamp +requestId +traceId +method +routeTemplate +status +duration +requestBytes +responseBytes +apiVersion +actorFingerprint // 정책 허용 시 +normalized client-IP // 개인정보 정책 적용 +``` + +body logging은 기본 비활성으로 두는 것이 맞습니다. + +Audit은 access log와 목적이 다릅니다. + +```text +관리자 API +강제 삭제 +권한 변경 +redrive +sunset 변경 +idempotency 수동 해제 +``` + +는 별도 durable audit stream으로 보내야 합니다. + +## 테스트 전략과 지원 매트릭스 + +### Contract·기능·오류 테스트 + +MockMvc나 WebTestClient는 빠른 Web-layer 검증에 적합하지만 WebTestClient 자체도 mock request/response와 실제 running server 두 방식 모두를 지원합니다. 따라서 network semantics가 필요한 검증을 mock에 의존하지 않는 구성이 가능합니다. citeturn18search15 + +최소 Release Gate는 다음 매트릭스를 가져야 합니다. + +| 영역 | 필수 시나리오 | +|---|---| +| Routing | 존재/미존재 path, method, trailing slash, encoded path | +| Negotiation | bad Content-Type, bad Accept, language | +| Binding | missing/null/empty/duplicate | +| JSON | malformed, unknown field, duplicate key, depth | +| Validation | field/nested/collection/cross-field | +| Success | 200/201/202/204 | +| Error | 400/401/403/404/409/412/422/429/5xx | +| Headers | Location, ETag, Cache-Control, Vary, Allow | +| HEAD | GET metadata 일치, body 없음 | +| Conditional | 304, If-Match, If-None-Match | +| Problem | type/code/status/pointer, no stack | +| Versioning | old/new/default/unsupported | +| Deprecation | Deprecation/Sunset/Link | +| Pagination | limit, cursor, stable sort | +| OpenAPI | snapshot, breaking diff | + +### Idempotency·응답 유실 테스트 + +이 영역은 일반 Controller test와 별개로 fault-injection이 필요합니다. + +```text +같은 key + 같은 payload 직렬 요청 +같은 key + 같은 payload 동시 요청 +같은 key + 다른 payload +PROCESSING 중 process crash +business commit 직전 crash +business commit 직후 crash +idempotency record commit 전 crash +response headers 전 reset +response body 일부 후 reset +response 완전 write 후 client-side timeout +TTL 경계 +expired key reuse +operation reconciliation +``` + +가장 중요한 성공 조건은 다음입니다. + +```text +business commit 후 응답 유실 +→ 같은 key 재요청 +→ business mutation 두 번 발생하지 않음 +→ 이전 result 또는 reconciliation reference 반환 +``` + +### Streaming 테스트 + +```text +정상 complete +empty stream +slow consumer +server producer burst +bounded buffer +heartbeat +idle timeout +max stream age +client disconnect +server shutdown +partial item 후 exception +terminal error frame +abrupt EOF +SSE Last-Event-ID +resume success +resume gap +event log unavailable +``` + +특히 **200이 이미 commit된 이후 application error가 발생해도 테스트가 500을 기대하면 안 됩니다.** 이 경우 transport stream contract를 검증해야 합니다. + +### Security·Proxy 테스트 + +```text +CORS preflight +credentialed CORS +disallowed Origin +CSRF token missing/invalid +BOLA +property authorization +mass assignment +tenant bypass + +raw Host injection +Forwarded injection +X-Forwarded-For spoof +X-Forwarded-Prefix spoof +scheme confusion +absolute Location poisoning +open redirect +``` + +Nginx를 포함한 실제 topology test가 필요합니다. + +```text +Client +→ HTTPS Nginx +→ HTTP/HTTP2 internal +→ Spring +``` + +이 시험에서 `Location`, secure redirect, external scheme, host, prefix, client IP가 기대값과 일치해야 합니다. + +### Abuse·성능·실서버 테스트 + +```text +header limit +URI limit +body limit +deep JSON +huge arrays +decompression bomb +slow request body +slow response consumer +connection flood +request flood +write flood +expensive query flood +``` + +서버별 lane: + +| Stack | 필수 | +|---|---| +| MVC + Tomcat | Stable gate | +| MVC + Jetty | compatibility gate | +| MVC + Virtual Thread | Advanced performance gate | +| WebFlux + Reactor Netty | WebFlux Stable gate | +| Nginx + chosen server | production topology gate | + +Spring Boot는 Tomcat, Jetty, Reactor Netty를 포함해 graceful shutdown을 지원하고 shutdown grace 동안 새 request를 받지 않으면서 in-flight 요청을 처리하는 기능을 제공합니다. 다만 실제 request rejection 동작은 web server와 persistent connection에 따라 차이가 있으므로 실제 server에서 확인해야 합니다. citeturn16search26turn16search20 + +Performance test에는 평균 latency보다 다음 지표가 중요합니다. + +```text +p50 / p95 / p99 latency +throughput +active requests +queued requests +rejected requests +heap +GC +thread count +virtual thread count +event-loop saturation +DB connection pool +response write latency +stream buffer +client disconnect rate +``` + +### Stable·Advanced·Experimental·비지원 + +최종 공개 범위는 다음과 같이 정리하는 것을 권고합니다. + +| 기능 | 등급 | +|---|---| +| MVC Controller/DTO | **Stable** | +| Tomcat MVC | **Stable 기본** | +| Jetty MVC | Stable compatibility | +| Validation | **Stable** | +| RFC 9457 Problem Details | **Stable** | +| path major version | **Stable** | +| pagination/Slice | **Stable** | +| keyset cursor | **Stable** | +| ETag/conditional GET | **Stable** | +| If-Match mutation | **Stable** | +| OpenAPI 3.1.2 | **Stable** | +| Idempotency for registered mutation | **Stable capability** | +| `Idempotency-Key`를 “IETF 표준”으로 선언 | **금지** | +| durable 202 operation | **Stable W2** | +| SSE | Advanced | +| NDJSON | Advanced | +| JSON Merge Patch RFC 7396 | Advanced | +| JSON Patch RFC 6902 | Advanced | +| WebFlux | Stable 선택 Profile | +| Functional WebFlux endpoints | Advanced | +| MVC virtual threads | Advanced | +| CBOR/XML | Optional Advanced | +| OpenAPI 3.2 output | Experimental | +| new RateLimit header draft | Experimental | +| raw `ServletRequest` 도메인 사용 | 비지원 | +| raw `ServerWebExchange` 도메인 사용 | 비지원 | +| Controller transaction | 비지원 | +| Entity 직접 request/response | 비지원 | +| arbitrary `Map` API | 비지원 | +| WebFlux event-loop에서 blocking JPA | 비지원 | +| Web에서 durable SSE replay 저장 | 비지원 → Messaging | +| Web에서 binary large file | 비지원 → Fileserver | +| Web에서 bidirectional messaging | 비지원 → WebSocket | +| 모든 응답 `ApiResponse` | 기본 비지원 | + +## 단계별 구현 순서와 완료 조건 + +이 플랫폼은 한 번에 모든 W1~W4 기능을 구현하기보다 **HTTP 의미론 → 실행 증거 → 운영 기능** 순서로 올리는 것이 위험이 가장 낮습니다. + +### 기반 계약 + +먼저 `web-core-api`, MVC starter, DTO/Controller 규칙, JSON profile, RFC 9457, HTTP status/header policy를 완성합니다. + +```text +완료 조건 + +MVC + Tomcat 실제 서버 기동 +MVC/WebFlux starter 동시 존재 시 fail-fast +Entity response/request 정적 검사 또는 ArchUnit 규칙 +400/404/405/406/415/422/500 Problem contract +201 Location +204 no-body +HEAD semantics +Content-Type/Accept tests +``` + +Spring Boot 4.1.0 + Framework 7.0.8 조합을 BOM의 유일한 Spring 버전 source로 둡니다. citeturn2search3 + +### 계약 진화 + +다음으로 API versioning, deprecation, OpenAPI snapshot/diff, pagination catalog를 추가합니다. + +```text +완료 조건 + +/api/v1 route inventory +OpenAPI 3.1.2 snapshot +breaking diff CI +generated client compile +Deprecation/Sunset response +cursor tamper test +sort/filter allowlist +``` + +OpenAPI 3.2는 별도 compatibility job으로만 생성해 봅니다. OAS 3.2의 streaming 표현력은 분명히 향상됐지만 현재 generator/tooling lane을 통과한 뒤 Stable로 승격하는 것이 좋습니다. citeturn18search2turn18search0 + +### 동시성·상태 변경 안전성 + +그 다음 ETag/If-Match와 idempotency를 구현합니다. + +```text +완료 조건 + +GET → ETag +If-Match success/failure +create-only conditional +Idempotency-Key scope +request fingerprint +concurrent same-key exclusion +same-key different request rejection +same-key result replay +DB commit + idempotency evidence atomicity +response-loss fault test +``` + +여기서 **Commit Evidence를 구현하지 못한 상태에서 자동 retry를 제공하면 안 됩니다.** + +### 비동기 Operation + +Messaging/outbox와 연결해 `202`를 구현합니다. + +```text +완료 조건 + +durable acceptance 후에만 202 +Operation Resource +poll +resultLocation +failure Problem +cancellation +expiration +restart recovery +duplicate submission +``` + +RFC 9110의 202가 처리 완료 자체를 보장하지 않는다는 점 때문에, 이 durable operation model이 HTTP 위에 플랫폼이 추가해야 할 핵심 계약입니다. citeturn15view0 + +### Security·Proxy·Budget + +Nginx를 실제로 붙인 topology test를 수행합니다. + +```text +완료 조건 + +trusted proxy boundary +Forwarded stripping +external URL +CORS +CSRF +object/property auth +body/header/path limits +rate limit +admission +429/503 +slow client tests +``` + +Spring이 권고하는 것처럼 forwarded headers는 애플리케이션에서 임의로 신뢰하지 않고 경계 proxy가 sanitize해야 합니다. citeturn10search0 + +### Streaming과 WebFlux + +기본 Unary-style HTTP API가 안정화된 뒤 streaming을 추가합니다. + +```text +완료 조건 + +SSE heartbeat +idle timeout +max stream age +bounded buffering +partial-write error contract +client disconnect +shutdown drain +Messaging resume integration +Reactor Netty real-server tests +``` + +MVC streaming은 별도 production executor가 없으면 Stable로 선언하지 않습니다. Spring도 기본 async executor가 부하 환경에 적합하지 않음을 경고합니다. citeturn1search0 + +### 최종 운영 Gate + +마지막으로 W4를 활성화합니다. + +```text +Route Inventory +OpenAPI Publish +Breaking Diff +Deprecated Route Usage +Problem Catalog Inventory +Metric Cardinality Test +Access Log Redaction Test +Audit Event Test +Fault Injection +Load Test +Nginx Contract Test +Graceful Shutdown Test +``` + +최종적으로 이 `web` 플랫폼의 핵심 불변식은 다음 일곱 가지로 압축할 수 있습니다. + +```text +HTTP Status는 업무 결과를 숨기지 않는다. + +Controller는 Application Use Case Adapter다. +Transaction이나 Persistence 경계가 아니다. + +Application Commit과 HTTP Response Delivery는 별개의 증거다. + +상태 변경 재시도는 Status Code가 아니라 +Idempotency Evidence로 판단한다. + +ETag/If-Match는 동시성 제어이고 +Idempotency는 중복 실행 제어다. + +Response가 commit된 Streaming에서는 +ProblemDetail로 HTTP status를 다시 바꿀 수 없다. + +HTTP cache, Proxy, Security, OpenAPI, Observability도 +Controller 외부의 부가 기능이 아니라 +공개 HTTP 계약의 일부다. +``` + +이 원칙을 기준으로 하면 이번 `web`은 “Spring MVC 공통 코드”가 아니라, **요청이 어디까지 실행됐는지, mutation이 실제로 commit됐는지, 재호출이 안전한지, partial response 이후 무엇을 복구할 수 있는지를 명시적으로 판정하는 인바운드 HTTP 실행 플랫폼**으로 자리 잡게 됩니다. \ No newline at end of file diff --git a/docs/web-superpowers-package/validate_web_docs.py b/docs/web-superpowers-package/validate_web_docs.py new file mode 100755 index 00000000..64ceab95 --- /dev/null +++ b/docs/web-superpowers-package/validate_web_docs.py @@ -0,0 +1,286 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import hashlib +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent + +if (ROOT / "docs").exists(): + DESIGN = ROOT / "docs/superpowers/specs/2026-08-13-web-inbound-http-api-execution-platform-design.md" + STABLE = ROOT / "docs/superpowers/plans/2026-08-13-web-inbound-http-api-execution-platform-implementation-plan.md" + ADVANCED = ROOT / "docs/superpowers/plans/2026-08-13-web-advanced-capabilities-expansion-plan.md" + RESEARCH = ROOT / "research/source-web-deep-research.md" +else: + DESIGN = ROOT / "web-inbound-http-api-execution-platform-design.md" + STABLE = ROOT / "web-inbound-http-api-execution-platform-implementation-plan.md" + ADVANCED = ROOT / "web-advanced-capabilities-expansion-plan.md" + RESEARCH = ROOT / "붙여넣은 마크다운(1)(20260813-120656).md" + +checks: list[tuple[str, bool, str]] = [] + +def check(name: str, condition: bool, detail: str = "") -> None: + checks.append((name, bool(condition), detail)) + +def read(path: Path) -> str: + check(f"file exists: {path.name}", path.exists(), str(path)) + return path.read_text(encoding="utf-8") if path.exists() else "" + +design = read(DESIGN) +stable = read(STABLE) +advanced = read(ADVANCED) +research = read(RESEARCH) + +check("design line floor", len(design.splitlines()) >= 1000, str(len(design.splitlines()))) +check("stable plan line floor", len(stable.splitlines()) >= 3000, str(len(stable.splitlines()))) +check("advanced plan line floor", len(advanced.splitlines()) >= 900, str(len(advanced.splitlines()))) +check("research line floor", len(research.splitlines()) >= 1000, str(len(research.splitlines()))) + +for name, text in [ + ("design", design), + ("stable", stable), + ("advanced", advanced), +]: + check(f"{name} markdown fence balanced", text.count("```") % 2 == 0, str(text.count("```"))) + for forbidden in ["TODO", "TBD", "FIXME", "implement later", "fill in details"]: + check(f"{name} has no placeholder {forbidden}", forbidden not in text, forbidden) + +def task_sections(text: str) -> list[tuple[int, str]]: + matches = list(re.finditer(r"(?m)^### Task (\d+): .+$", text)) + result: list[tuple[int, str]] = [] + for index, match in enumerate(matches): + start = match.start() + end = matches[index + 1].start() if index + 1 < len(matches) else len(text) + result.append((int(match.group(1)), text[start:end])) + return result + +stable_tasks = task_sections(stable) +advanced_tasks = task_sections(advanced) + +check("stable task count", len(stable_tasks) == 58, str(len(stable_tasks))) +check("advanced task count", len(advanced_tasks) == 19, str(len(advanced_tasks))) +check( + "stable task numbering consecutive", + [number for number, _ in stable_tasks] == list(range(1, 59)), + str([number for number, _ in stable_tasks]), +) +check( + "advanced task numbering consecutive", + [number for number, _ in advanced_tasks] == list(range(1, 20)), + str([number for number, _ in advanced_tasks]), +) + +required_markers = [ + "**Files:**", + "**Interfaces:**", + "**Implementation requirements:**", + "**Step 1: Write the failing test**", + "**Step 2: Run the focused test and verify the expected failure**", + "**Step 3: Implement the minimum production contract**", + "**Step 4: Run the task test and its module contract suite**", + "**Step 5: Commit the independently reviewable change**", + "git commit -m", +] + +for plan_name, sections in [("stable", stable_tasks), ("advanced", advanced_tasks)]: + for number, section in sections: + for marker in required_markers: + check( + f"{plan_name} task {number} contains {marker}", + marker in section, + marker, + ) + check( + f"{plan_name} task {number} has test path", + "- Test: `" in section, + "", + ) + check( + f"{plan_name} task {number} has exact run command", + "Run: `" in section, + "", + ) + check( + f"{plan_name} task {number} has expected failure", + "Expected:" in section and "FAIL" in section, + "", + ) + check( + f"{plan_name} task {number} has expected pass", + "Expected: PASS" in section, + "", + ) + +def create_paths(text: str) -> list[str]: + return re.findall(r"(?m)^- Create: `([^`]+)`$", text) + +stable_creates = create_paths(stable) +advanced_creates = create_paths(advanced) + +check( + "stable create paths unique", + len(stable_creates) == len(set(stable_creates)), + f"{len(stable_creates)} paths", +) +check( + "advanced create paths unique", + len(advanced_creates) == len(set(advanced_creates)), + f"{len(advanced_creates)} paths", +) +check( + "stable and advanced create paths do not collide", + set(stable_creates).isdisjoint(set(advanced_creates)), + str(set(stable_creates) & set(advanced_creates)), +) + +design_terms = [ + "W1", + "W2", + "W3", + "W4", + "APPLICATION_COMMITTED", + "CLIENT_OBSERVATION_UNKNOWN", + "RFC 9457", + "OpenAPI 3.1.2", + "If-Match", + "Idempotency", + "202 Accepted", + "Tomcat", + "Jetty", + "Reactor Netty", + "Nginx", + "business mutation + authoritative evidence same DB transaction", + "Redis", + "Request Evidence", + "Application Evidence", + "Response Evidence", +] +for term in design_terms: + check(f"design contains key term: {term}", term in design, term) + +stable_terms = [ + "same-PostgreSQL-transaction", + "Application Commit 후 HTTP Response 유실 Fault Test", + "Redis Concurrent Gate와 Replay Cache Adapter", + "실제 Nginx Trusted Proxy", + "실제 Tomcat MVC HTTP 계약 Gate", + "Jetty MVC 호환성 Gate", + "실제 Reactor Netty WebFlux 계약 Gate", + "OpenAPI 3.1.2 Snapshot", + "OpenAPI Breaking Diff", + "Durable Operation", + "same-PostgreSQL-transaction", + "DB commit evidence의 유일한 source가 아니다", + "webStableCheck", +] +for term in stable_terms: + check(f"stable plan contains key term: {term}", term in stable, term) + +advanced_terms = [ + "Virtual Thread", + "Controlled Blocking Bridge", + "JSON Merge Patch RFC 7396", + "JSON Patch RFC 6902", + "MVC SSE", + "WebFlux SSE", + "NDJSON", + "JSON Text Sequence", + "Messaging-backed SSE Replay", + "OpenAPI 3.2 Experimental", + "RateLimit Draft", + "10k", + "rollback", +] +for term in advanced_terms: + check(f"advanced plan contains key term: {term}", term in advanced, term) + +# Stable/advanced dependency boundary. +check( + "stable module map excludes modules/web-advanced", + "modules/web-advanced/" not in stable.split("## 1. Stable 파일·모듈 구조", 1)[1].split("## 2.", 1)[0], + "", +) +check( + "advanced plan requires stable completion", + "Stable Task 1~58" in advanced, + "", +) + +# Evidence and idempotency invariants. +for text_name, text in [("design", design), ("stable", stable)]: + check( + f"{text_name} separates ETag and idempotency", + "ETag/If-Match" in text and "Idempotency" in text, + "", + ) + check( + f"{text_name} says Redis is not sole commit evidence", + ("sole DB commit evidence" in text) + or ("유일한 source" in text) + or ("유일한 Source" in text), + "", + ) + check( + f"{text_name} includes commit-response-loss", + ("Response Loss" in text) + or ("response-loss" in text) + or ("response write 전 TCP reset" in text), + "", + ) + +# No unsupported architecture in design/plan. +for name, text in [("design", design), ("stable", stable)]: + check( + f"{name} forbids controller transaction", + "Controller transaction" in text or "Controller @Transactional" in text or "Controller 또는 HTTP adapter에 업무 `@Transactional`" in text, + "", + ) + check( + f"{name} forbids entity/document wire types", + "Entity/Document" in text or "Entity·Document" in text or "JPA Entity·MongoDB Document" in text, + "", + ) + check( + f"{name} does not declare Idempotency-Key as final RFC", + "IETF 표준" not in text or "금지" in text, + "", + ) + +# Research grounding. +check( + "design title matches research topic", + "인바운드 HTTP API 실행 플랫폼" in design and "인바운드 HTTP API 실행 플랫폼" in research, + "", +) +check( + "research includes execution evidence chain", + "HTTP_RECEIVED" in research and "CLIENT_OBSERVATION_UNKNOWN" in research, + "", +) +check( + "research includes actual server matrix", + "MVC + Tomcat" in research and "WebFlux + Reactor Netty" in research, + "", +) + +# Optional package integrity. +manifest = ROOT / "MANIFEST.sha256" +if manifest.exists(): + for line in manifest.read_text(encoding="utf-8").splitlines(): + if not line.strip(): + continue + digest, relative = line.split(" ", 1) + target = ROOT / relative + actual = hashlib.sha256(target.read_bytes()).hexdigest() if target.exists() else "" + check(f"manifest: {relative}", actual == digest, actual) + +passed = sum(1 for _, ok, _ in checks if ok) +failed = [(name, detail) for name, ok, detail in checks if not ok] + +print(f"checks={len(checks)} passed={passed} failed={len(failed)}") +for name, detail in failed: + print(f"FAIL: {name} :: {detail}") + +sys.exit(1 if failed else 0) diff --git a/docs/web/advanced-capabilities.md b/docs/web/advanced-capabilities.md new file mode 100644 index 00000000..9f398b3f --- /dev/null +++ b/docs/web/advanced-capabilities.md @@ -0,0 +1,59 @@ +# Web Advanced: support matrix + +Every capability is off unless named. `WebAdvancedPromotionGate.forFeature` is the machine-checked +form of the last two columns; if this table disagrees with it, the code wins. + +| Capability | Flag | What it adds | Required suites | Soak | +| --- | --- | --- | --- | --- | +| `MVC_VIRTUAL_THREADS` | `backend.web.advanced.mvc-virtual-threads.enabled` | A different scheduling model for **every** request | `web:test`, `webCrossStackParityTest`, `virtual-thread-admission`, `pinning-jfr` | 24h | +| `WEBFLUX_BLOCKING_BRIDGE` | `…webflux-blocking-bridge.enabled` | A bounded offload; affects the shared event loop | `web:test`, `blocking-bridge-bounded`, `event-loop-guard` | 24h | +| `JSON_MERGE_PATCH` | `…json-merge-patch.enabled` | RFC 7396, partial documents | `web:test`, `patch-security`, `patch-atomicity` | 8h | +| `JSON_PATCH` | `…json-patch.enabled` | RFC 6902, arbitrary pointers | as merge patch | 8h | +| `SSE` | `…sse.enabled` | Long-lived connections | `web:test`, `streaming-soak-10k`, `slow-consumer-bounded`, `cancellation-propagation`, `pod-drain` | 24h | +| `NDJSON` | `…ndjson.enabled` | Long-lived connections | as SSE | 24h | +| `JSON_SEQUENCE` | `…json-sequence.enabled` | Long-lived connections | as SSE | 24h | +| `FUNCTIONAL_WEBFLUX` | `…functional-webflux.enabled` | Routes with no annotation to scan | `web:test`, `functional-route-parity` | 8h | +| `CBOR` | `…cbor.enabled` | A second decoder | `web:test`, `codec-security`, `codec-budget` | 8h | +| `XML` | `…xml.enabled` | A decoder with dangerous defaults | as CBOR | 8h | +| `OPENAPI_32` | `…openapi-32.enabled` | A parallel description artifact | `web:test`, `openapi-32-toolchain-matrix` | 8h | +| `RATELIMIT_DRAFT_HEADERS` | `…ratelimit-draft-headers.enabled` | Additive response headers | `web:test`, `ratelimit-draft-headers` | 8h | + +## Two capabilities change requests that do not use them + +`WebAdvancedFeature.affectsUnrelatedRequests()` is true for exactly `MVC_VIRTUAL_THREADS` and +`WEBFLUX_BLOCKING_BRIDGE`. A codec affects only requests that negotiate it; a virtual-thread +executor affects every request in the process, and the blocking bridge affects the event loop they +all share. `WebAdvancedFeatureFlags.stableBehaviourPreserved()` reports whether either is on. + +That distinction is why those two soak for 24 hours and why their rollback test is the one that +matters most. + +## What is refused, and why the row is here + +| Refused | Reason | +| --- | --- | +| Virtual threads without an admission limit | The pool size *was* the admission policy. Removing it accepts every arrival and queues them on downstream budgets that did not grow. | +| A blocking offload for an unregistered operation | `boundedElastic()` is reachable from anywhere and unbounded in practice; an unenumerable set of offloads is invisible until the pool is the heap. | +| A merge patch outside its field allowlist | A merge patch is partial, so no DTO's absent fields say "not permitted". Without an allowlist the writable set grows every time somebody adds a field. | +| A JSON Patch pointer above its allowed prefix | Replacing a parent deletes every sibling, so permission on a child cannot grant it. | +| A `move` whose source is unauthorized | Checking only the destination lets a caller relocate data out of a field they may not touch. | +| XML with a DTD or an external entity | Billion-laughs and XXE. Neither errors when it fires; the parse succeeds and the document contains something it should not. | +| A non-JSON representation without all three gates | Flag, route `produces`, and client allowlist. An `Accept` header is not evidence the route was tested against that codec. | +| Compression-style silent fallback on a 406 | A client that asked only for CBOR and gets JSON parses the bytes as CBOR and fails somewhere far away. | +| Changing HTTP status after commit | The body becomes half stream, half JSON, and the 200 is cached. | +| Unbounded buffering for a slow consumer | It moves the client's slowness into the server's heap. | + +## Promotion + +Each capability is promoted on its own evidence. Two conditions apply to all and are not waivable: + +- **Rollback exercised.** A flag nobody has turned off is not known to turn off. +- **Stable behaviour unchanged with the feature off.** If it is not, the feature was never optional + and every deployment has it. + +Parsers and patch appliers additionally require a security review — +`WebAdvancedPromotionGate.needsSecurityReview` names them. + +See `docs/adr/ADR-WEB-ADV-001-streaming-is-live-delivery.md`, +`ADR-WEB-ADV-002-virtual-threads-do-not-remove-admission.md`, and +`ADR-WEB-ADV-003-openapi-32-remains-experimental.md`. diff --git a/docs/web/openapi-32-compatibility.md b/docs/web/openapi-32-compatibility.md new file mode 100644 index 00000000..4c3abeb6 --- /dev/null +++ b/docs/web/openapi-32-compatibility.md @@ -0,0 +1,57 @@ +# OpenAPI 3.2: the experimental lane + +## Why it is a lane rather than an upgrade + +Generating 3.2 is cheap. Adopting it is not, and the two get conflated because the generated +document looks fine. + +The value of an API description is entirely in what consumes it. A document in a version a client +generator does not fully understand does not fail — it produces a client that compiles and is wrong, +which is worse than no document at all. + +So 3.1.2 stays the release artifact and 3.2 is generated beside it, with a report. + +## The invariant + +**Generating 3.2 must not change the 3.1 snapshot.** Both come from the same model, so a contributor +that mutates it on the way to 3.2 changes the artifact that is actually shipped — silently, and only +when the experimental lane runs. + +`OpenApi32CompatibilityReport` hashes the snapshot before and after, and a difference is a promotion +blocker with its own message. + +## The toolchain matrix + +Four kinds of tool, checked separately, because passing one says nothing about the others: + +| Kind | What it catches | What it misses | +| --- | --- | --- | +| Parser | Structural errors | Anything semantically odd | +| Linter | Style rule violations | Accepts documents a parser rejects | +| Generator | Unsupported constructs — sometimes | Usually emits a wrong-but-valid signature instead of failing | +| Client compile | The wrong signature the generator emitted | Runtime behaviour | + +`OpenApiToolchainMatrix.complete()` requires at least one passing tool of each kind. +`gaps()` names the kinds that have none. + +"OpenAPI 3.2 works" is not a statement anybody can make. "This document is read correctly by these +four tools at these versions" is. + +## Streaming descriptions + +The substantive difference between 3.1 and 3.2 for this application is how streaming responses are +described. Those differences are reported separately rather than folded into a pass/fail, because a +green pass hides what changed. + +## Promotion + +`promotionBlockers(adrAccepted)` always includes the ADR blocker until one is accepted, however +green the matrix is. A machine-checkable matrix cannot decide whether the consumer population is +ready; that is a judgement, and it belongs in +`docs/adr/ADR-WEB-ADV-003-openapi-32-remains-experimental.md`. + +## Running it + +The experimental generation runs in its own workflow and publishes the 3.2 document and the +compatibility report as artifacts. It never runs in the release workflow, so the release artifact +cannot depend on whether it ran. diff --git a/docs/web/patch-contract.md b/docs/web/patch-contract.md new file mode 100644 index 00000000..7b58b605 --- /dev/null +++ b/docs/web/patch-contract.md @@ -0,0 +1,78 @@ +# Web patch: the client contract + +Two patch formats, and they are not interchangeable. + +## Choosing one + +A route accepts exactly one, and sending the other is a 415 rather than a best-effort guess. The +reason is that a mismatch is silent in the worst direction: + +- `{"a": null}` as a **merge patch** deletes `a`. As a **JSON Patch** it is not a patch at all — it + is an object where an array was required. +- A JSON Patch array read as a merge patch is a document whose fields are array indices. It merges + nothing and reports success. + +| | Merge patch | JSON Patch | +| --- | --- | --- | +| Media type | `application/merge-patch+json` | `application/json-patch+json` | +| RFC | 7396 | 6902 | +| Shape | A partial document | An ordered array of operations | +| Delete | `"field": null` | `{"op":"remove","path":"/field"}` | +| Arrays | Replaced whole | Addressable per index | +| Preconditions | `If-Match` only | `If-Match`, plus per-field `test` | + +Use merge patch for "change these fields". Use JSON Patch when you need array element edits or a +precondition on one field that `If-Match` cannot express. + +## What is refused + +**Fields outside the allowlist.** The server declares which fields a merge patch may modify and +which pointers a JSON Patch may address. Anything else is refused, and *every* refused path is +named — you will not discover them one round trip at a time. + +The allowlist is checked **before** anything is applied. A refusal tells you nothing about the +current values, which is deliberate: deciding afterwards whether a refused field "actually changed +anything" would answer a question about data you may not read. + +**Pointer permission does not travel upwards.** Permission on `/profile` covers +`/profile/displayName`; permission on `/profile/displayName` does not cover `/profile`, because +replacing the parent deletes every sibling. + +**A `move` needs permission on both ends.** Checking only the destination would let you relocate +data out of a field you may not touch. + +**Limits.** At most 100 operations, pointer depth at most 16, merge-patch nesting depth at most 32. +The first two multiply — each operation walks its pointer over a document the server deep-copied +first. + +## Atomicity + +A JSON Patch document applies entirely or not at all. Every operation runs against a working copy; +the resource is not touched until all of them have succeeded and the result has passed full +validation. + +This is what makes `test` useful. A client putting a `test` first is relying on nothing after it +having happened when the test fails, and that reliance holds: + +```json +[ + {"op": "test", "path": "/version", "value": 4}, + {"op": "replace", "path": "/displayName", "value": "new"} +] +``` + +A failed `test` is **409**, not 400: the document was well-formed and permitted, and the resource +simply was not in the state you expected. Re-read and retry. The response names the pointer that +failed and does not return the server's value — returning it would make `test` a read primitive for +fields you may not read. + +## Validation + +The patched result is validated as a whole document, not field by field. A patch whose individual +fields are all valid can produce an object that is not — two fields that must agree, a state +transition that is not allowed — and validating only what changed sees none of it. + +## Preconditions + +Patch routes require `If-Match`. A partial update against a resource that moved underneath you +applies your changes to a version you never saw. diff --git a/docs/web/repository-adaptation.md b/docs/web/repository-adaptation.md new file mode 100644 index 00000000..5ba4dad4 --- /dev/null +++ b/docs/web/repository-adaptation.md @@ -0,0 +1,357 @@ +# Web platform — repository adaptation + +The inbound HTTP API execution platform design +(`docs/web-superpowers-package/docs/superpowers/specs/2026-08-13-web-inbound-http-api-execution-platform-design.md`) +was written against a standalone repository. Four of its assumptions do not hold here, and this +document records what each became and why, so a reader comparing the design to the tree is not left +guessing whether a difference is a decision or a mistake. + +## 1. Twenty-three Gradle modules became sub-packages of one leaf + +The design lays the platform out as `modules/web/web-core-api`, `web-contract`, `web-mvc` and so on. +This repository's `src/config/architecture/modules.json` is a fail-closed registry that owns the leaf +list, and adding twenty-three leaves to it is an architecture decision the design cannot make on the +repository's behalf. The JPA and GraphQL platforms reached the same fork and resolved it the same +way, so this is the established answer rather than a new one. + +The substitution only holds if the boundaries are enforced, because a Gradle dependency gate cannot +see inside a leaf. `WebStableModule` declares each module's identity, package, purity grade and +allowed edges; `WebModuleBoundaryTest` scans the real source tree and fails when the tree and the +declaration disagree **in either direction** — an undeclared edge, an undeclared package, or a +declared module with no source. Four negative fixtures prove each rule can fail. + +Each enum constant is already shaped like a leaf specification, so promoting a module to its own +Gradle path later is a registry edit rather than an archaeology exercise. + +| Design module | Package under `dev.caskeleton.adapter.inbound.web` | Status | +| --- | --- | --- | +| `web-core-api` | `core` | planned (Task 2+) | +| `web-contract` | `contract` | planned | +| `web-validation` | `validation` | planned | +| `web-error` | `error` | present | +| `web-pagination` | `pagination` | present | +| `web-idempotency` | `idempotency` | present | +| `web-idempotency-jpa` | `idempotency.jpa` | planned | +| `web-idempotency-redis` | `idempotency.redis` | planned | +| `web-versioning` | `versioning` | planned | +| `web-security-integration` | `auth`, `authz` | present | +| `web-observability` | `observability` | present | +| `web-openapi` | `openapi` | planned | +| `web-mvc` | `mvc` | planned | +| `web-webflux` | `webflux` | planned | +| `web-admin` | `admin` | planned | +| `web-operation-jpa` | `operation.jpa` | planned | +| `web-operation-messaging` | `operation.messaging` | planned | +| `web-spring-boot-starter-mvc` | `autoconfigure.mvc` | planned | +| `web-spring-boot-starter-webflux` | `autoconfigure.webflux` | planned | +| `web-testkit-*` | `testkit`, `testkit.mvc`, `testkit.webflux`, `testkit.contract` | planned | +| — | `conditional`, `cursor`, `http`, `filter`, `envelope`, `config`, `controller`, `ratelimit`, `settings` | present, pre-dates the design | +| — | `fileserver.*`, `notification.*` | present, feature integrations rather than platform modules | + +A module is added to `WebStableModule` at the moment its package gains its first file, never before: +a declared module with no source is a claim about a rename that has not happened. + +## 2. Root package + +The design uses `io.backend.skeleton.web`. This repository's package root is +`dev.caskeleton.adapter.inbound.web`, and the registry, the ArchUnit rules and the composition root's +component scan are all written against it. + +## 3. Gradle DSL + +The design's snippets are Kotlin DSL. Every build file in this repository is Groovy DSL, and +`src/build-logic` carries the shared convention plugins the leaf inherits. The build logic is +translated, not copied. + +## 4. Spring Boot baseline + +The design names "Spring Boot 4.1 BOM". This repository's baseline is **4.0.8** — see +`.vscode/settings.json` for why the 4.1 minor line is a planned migration rather than a currency +fix. Where the design depends on a 4.1-only API the difference is recorded at the call site rather +than silently absorbed. + +## 5. Sample application + +The design's `examples/web-platform-sample` is the existing `sample-portfolio` leaf, which already +carries the OpenAPI drift gate and the contract lanes the design's sample is specified to provide. + +## 6. Idempotency: the design's store SPI is already owned by `application-core` + +The design gives `web-idempotency` its own `IdempotencyStore`, `IdempotencyRecord`, +`IdempotencyState`, `IdempotencyScope` and `RequestFingerprint`. This repository already has all +five in `application-core/idempotency`, with a JPA adapter +(`PostgreSqlOwnerSafeIdempotencyStore`, `IdempotencyStoreAdapter`) and a Redis cache behind them — +which is what the design's Tasks 39 and 40 ask for, built in the direction the registry permits. + +Implementing the design's SPI literally would have required +`adapter-outbound-persistence-jpa → adapter-inbound-web`, an edge the registry forbids and one that +points the wrong way regardless: the store is an application concern that two transports could +share, not something the HTTP layer owns. + +So the web leaf keeps only the parts that are genuinely HTTP and have no application-core +equivalent, and everything else binds to the existing port: + +| Design type | Here | +| --- | --- | +| `IdempotencyStore`, `IdempotencyRecord`, `IdempotencyState`, `IdempotencyScope` | `application-core/idempotency` (already present) | +| `RequestFingerprint` | `application-core/idempotency` — the web factory *produces* it | +| `ResponseSnapshot` | `application-core/idempotency/StoredResponse` | +| `IdempotencyKey` | web: the `Idempotency-Key` header's grammar and bounds | +| `FingerprintHeaderPolicy` | web: which HTTP headers change what a request means | +| `DeterministicCommandEncoder` | web: canonical JSON, because the application port's `ofSha256(byte[])` hashes raw bytes | +| `SemanticRequestFingerprintFactory` | web: builds the application type from operation, path identifiers, canonical body and selected headers | + +The last three are the substance the design adds over what was here. The application port's +`RequestFingerprint.ofSha256(byte[])` digests the raw request body, and raw bytes are not stable +across a client library that reorders JSON members or a proxy that reformats — the design names that +explicitly, and the semantic factory is the fix. + + +## 7. Durable operations: the design's `web-operation-jpa` cannot exist here + +The design gives the web platform its own persistence module — `modules/web/web-operation-jpa` — +holding a JPA store for long-running operations, with the port +(`DurableOperationStore`) in `web-core-api` beside `WebProblem`. + +`src/config/architecture/modules.json` forbids both halves of that: + +- `:adapter:inbound:web` may depend only on `domain-core`, `application-core` and + `shared-contract`, so the web leaf cannot take a JPA dependency. +- `:adapter:outbound:persistence-jpa` is an outbound adapter and cannot depend on an inbound one, + so a JPA store cannot see a web type. + +This is the same shape as §6 and takes the same resolution — the one the repository already used +for idempotency, where `IdempotencyStorePort` lives in `application-core` and +`IdempotencyStoreAdapter` in `persistence-jpa`: + +| Design type | Here | +| --- | --- | +| `DurableOperationStore` | `application-core/operation/DurableOperationStorePort` | +| `OperationSubmission` | `application-core/operation/DurableOperationSubmission` | +| the stored operation | `application-core/operation/DurableOperation` | +| the lease | `application-core/operation/OperationLease` | +| the stored failure | `application-core/operation/OperationFailure` — a code and an already-safe message, never an exception | +| `JpaDurableOperationStore`, `JpaOperationEntity`, `V002__web_operation.sql` | `persistence-jpa/operation/*`, `db/migration/postgresql/V11__durable_operation.sql` | +| `OperationResource`, `OperationStatus`, `OperationProgress`, `OperationId` | web `operationasync` — the HTTP projection | +| — | web `operationasync/OperationResourceFactory`, which is the projection itself | + +The split is not only a registry workaround; it puts each half where its invariants belong. A +problem document is an HTTP concept and has no business being persisted, so the stored failure is a +code plus a safe message and the factory turns it into a `WebProblem` through the same catalog and +the same sanitiser as every synchronous error. An async failure therefore cannot publish something +a synchronous one would have redacted, and a worker cannot extend the published code vocabulary by +writing a row — an unrecognised code becomes `INTERNAL_ERROR`. + +The state machine is enforced three times on purpose: in `DurableOperation`'s constructor, in the +`CHECK` constraints of `V11__durable_operation.sql`, and in the `WHERE` clause of every statement +that changes a row. The constructor catches application bugs, the constraints catch any other +writer, and the predicates make each transition atomic — a worker whose lease lapsed while it was +still working cannot record a result over the worker that took over, because its `UPDATE` matches +no row and it learns that from the affected count. + +## 8. Budget enforcement: what the edge stops and what the application stops + +The design (Task 49) requires that the difference between an edge proxy's limits and the +application's own be written down rather than discovered during an incident. Two limits on the same +dimension always exist, they are never equal, and which one fires decides what the caller sees. + +| Dimension | Edge (nginx) | Application | Who answers when crossed | +| --- | --- | --- | --- | +| Request line / URI | `large_client_header_buffers` (default 8k) | `maxUriBytes` | Edge first — nginx answers 414 with its own HTML | +| Request headers | `large_client_header_buffers` | `maxHeaderBytes` | Edge first — nginx 400, no problem document | +| Request body | `client_max_body_size` (default 1m) | `maxBodyBytes` (platform ceiling 8 MiB) | Whichever is smaller; nginx answers 413 with HTML | +| Query parameter count | not enforced | `maxQueryParameters` | Application, always | +| JSON depth / array size | not enforced | `maxJsonDepth`, `maxArrayElements` | Application, always | +| Execution time | `proxy_read_timeout` (default 60s) | `maxExecutionTime` (ceiling 2 min) | Edge first if the platform's is larger | +| Response size | not enforced | `maxResponseBytes` (ceiling 32 MiB) | Application, always | + +Two consequences worth stating, because both are counterintuitive: + +**An edge limit below the application's means clients never see a problem document.** nginx answers +its own HTML error page, so a client that branches on `ProblemCode` gets a body it cannot parse. +Where a dimension matters to clients, the edge limit must be set *above* the application's so the +application is the one that refuses. + +**An edge limit above the application's is not redundant.** It is the only thing standing between +the process and a body large enough to matter before the application's own meter has counted it. +Both belong; only their ordering is a decision. + +The enforcement itself never materializes a body to measure it. `WebBudgetMeter` counts bytes as +they move — through a wrapped `ServletInputStream` on the servlet side and a `doOnNext` on the +buffer flux on the reactive side — and throws at the byte that crosses. A check that read the body +in order to size it would be the heap exhaustion the check exists to prevent, so the contract asserts +directly that an oversized body never reaches the handler in full. + +On the way out the split is between committed and not: + +- **Not committed** — the response is reset, which discards the partial body *and* the meter's + count of it, and the overrun is answered as a problem document. Resetting only the buffer was the + first implementation and it failed: the meter still held the count of the discarded bytes, so the + small problem document was refused too and the client got the container's error page. +- **Committed** — there is no status left to send. The exception propagates, the connection ends + mid-document, and the client sees a truncated response. That is worse for the client than a clean + error and better than a short response it would accept as complete. + +Statuses are never restated. `BudgetProblemMapper.statusFor` reads `ProblemCatalog` through the +violation's code; an earlier draft kept its own violation-to-status table and disagreed with the +catalog on two entries, which `requireStatusAgreement` turned into a 500. One table, no drift. + +## 9. The Nginx lane: what a real proxy caught that no unit test could + +Task 55 asks for the proxy contract to be verified against a real Nginx. It is implemented as a +separate `nginxProxyTest` source set with a Testcontainers-managed `nginx:1.27-alpine`, run by +`./gradlew :adapter:inbound:web:webNginxProxyTest`. Its own lane because it is the only one that +needs Docker; folded into `test`, every developer's `check` would depend on a container runtime, +and the usual end of that is an `@Disabled` nobody notices. + +**TLS is terminated in configuration, not in the container.** The design describes an `ssl` listener. +What the application can observe about TLS is exactly one thing — that the edge set +`X-Forwarded-Proto: https` authoritatively — and a proxy that terminates TLS and one that declares +the scheme produce an identical request upstream. Generating a certificate per run would add a +second failure mode to a lane whose subject is header handling. The lane asserts the observable +property; it does not assert that Nginx can do TLS. + +**The defect the lane found on its first run.** The first configuration set the forwarded headers +once at the `server` level and added only `X-Forwarded-Prefix` per `location`. Six of the ten cases +failed. Nginx's inheritance rule for array directives is *replacement*: a single `proxy_set_header` +inside a `location` discards every `proxy_set_header` inherited from `server`. So none of the +security headers were sent, the application fell back to the upstream's own `Host`, and a client's +`X-Forwarded-Host` would have been trusted. + +That configuration reads as correct, is a shape found throughout the wild, and no test of the +application could detect it — the application's forwarded-header handling is thoroughly unit-tested +and every one of those tests still passed. The bug lived entirely in the seam. The headers now live +in `proxy_headers.conf` and are `include`d by each location. + +| Bound | Where it fires | What the client sees | +| --- | --- | --- | +| `client_max_body_size 2m` | Nginx, before the application | Nginx's HTML 413, no problem document | +| application `maxBodyBytes` | the application | RFC 9457 problem, `REQUEST_TOO_LARGE` | +| unknown prefix | Nginx | 404; only `/api/` and `/dev-api/` are routed at all | + +The first row is the §8 table's consequence made concrete: an edge limit below the application's +means clients never see a problem document for that dimension. + +## Advanced capabilities + +The Advanced expansion plan asks for eleven Gradle modules under `modules/web-advanced/`. They are +packages under `advanced.**` in this leaf, for the reason the Stable platform is one leaf: +`src/config/architecture/modules.json` is fail-closed and owns the leaf list, and eleven entries to +satisfy a directory layout is a registry change rather than an architecture one. + +What the design wanted from the separation is enforced instead by two machine checks: + +- **`WEB-ARCH-ADV`** in `WebArchitectureRules.stableDoesNotDependOnAdvanced()` fails the build when a + Stable class names an Advanced type. A feature flag decides whether a bean is created; it does + nothing about a compile-time edge, and one such edge makes Stable unbuildable without Advanced. +- **`WebStableModule`** declares eleven Advanced module identities with their own purity and edge + sets, checked by `WebModuleBoundaryTest`. Nine of the eleven are `CORE` — pure policy with no + framework import — which is stricter than the design's Gradle layout would have been. + +Two modules are `FRAMEWORK_BOUND` and had to be: `advanced-patch` and `advanced-codec`. Jackson's +tree model is the reason for the first — a merge patch's null-means-delete has no representation in +a Java object, so the applier works on nodes — and `XMLInputFactory` is the reason for the second. +A third, `advanced-stream-encoding`, is separated from the pure `advanced-stream` for the same +reason: framing serializes, and serializing binds to Jackson. + +### What was adapted rather than copied + +**`WebStreamEnvelope.Error` is named `Failure`.** A nested type called `Error` shadows +`java.lang.Error` inside its own file, so an unrelated `catch (Error e)` there would catch the wrong +thing. Error Prone's `JavaLangClash` refuses it outright, and the rename is the only difference from +the design's sealed hierarchy. + +**`WebStreamErrorPolicy`'s factory methods are `startOver` / `resumeFromPosition` / `nothingToDo`.** +The design's names collided with the record's own accessors, which Java rejects. + +**The codec backends are compile-only, and finding out why cost a broken composition root.** +`WebCborMapperFactory` and `WebXmlMapperFactory` are implemented, along with the parts that carry +the failure modes: `SecureXmlInputFactory` (the DTD and external-entity defaults, both of which +produce no error when they fire), `CodecBudget` (per representation, because a megabyte of CBOR can +declare an array of a billion elements in a handful of bytes), and `RepresentationNegotiationPolicy` +(three gates, because an `Accept` header is not evidence the route was tested against that codec). + +`jackson-dataformat-cbor` and `jackson-dataformat-xml` were declared `implementation` first, so that +a missing backend could not surface as a `NoClassDefFoundError` at the first request that negotiated +one. That reasoning was wrong about what the jars do. Spring Boot's Jackson auto-configuration +registers an `xmlMapper` and a `cborMapper` bean the moment each backend is on the runtime +classpath, and Spring registers an XML message converter with it. Two things followed, and only the +first was noisy: + +- The composition root held three `ObjectMapper` beans — `webStrictObjectMapper`, `xmlMapper`, + `cborMapper` — so every `@Autowired ObjectMapper` became ambiguous and the application would not + start. Six `app-bootstrap` tests failed with `UnsatisfiedDependencyException`. +- Every deployment silently began accepting `application/xml` request bodies. An XXE surface, + acquired by adding a dependency, on a capability that is supposed to be off unless a deployment + names it. + +So both are `compileOnly` plus `testImplementation`: the factories compile, their tests run against +real backends, and the runtime classpath belongs to the deployment that enables the capability. +`WebRepresentation.available()` turns an absent backend into a sentence naming the missing +coordinate. `RepresentationBackendScopeTest` reads `build.gradle` and fails if either coordinate +returns to `implementation`, because nothing else catches it — the codec's own tests pass either +way, and the failure only appears in whatever composes this leaf. + +**The framing and the writers are separate, and both exist.** `NdjsonFraming` and +`JsonSequenceFraming` hold the contract; `MvcStreamWriter`, `WebFluxStreamWriter` and +`WebFluxSseAdapter` are what actually put it on a response. There is no MVC SSE writer: this +repository's `NO_SSE_EMITTER` ArchUnit rule forbids `SseEmitter` outright, so the servlet stack +streams NDJSON and JSON-seq and SSE is reactive-only. An earlier pass +stopped after the framing, which left the platform advertising SSE while nothing could serve it — +the same "control reached by nothing" shape this leaf has caught five other times. + +**`MessagingReplayBridge` is `WebStreamReplaySource`, an interface this module implements nowhere.** +The design's requirement is that the web module store no durable event history; an implementation +here would be the thing it forbids. + +### What the execution layer had to get right + +Three of these were only found by building the adapter rather than the policy. + +**A merged heartbeat means a finished source never completes.** `Flux.interval` is infinite, so +`source.mergeWith(heartbeat)` holds the connection for the full `maxStreamAge` after the last item — +thirty minutes of keepalives on a stream that ended. `WebFluxSseAdapter` therefore ends at the +terminal envelope (`takeUntil`), and supplies one for a source that finished without emitting its +own. + +**`onBackpressureBuffer(n)` does not close anything.** It propagates demand, so a subscriber that +stops requesting simply stops the source and the bound never fires. That is correct for a +well-behaved source and useless as a slow-consumer policy, because the sources this carries push +whether or not anybody asked. The working composition is `onBackpressureBuffer(n)` followed by +`onBackpressureError()`, verified by mutation: removing the second half makes both slow-consumer +tests fail. The merge prefetch is set to the same `n`, because its default of 256 would otherwise +be a larger backlog sitting behind the configured one. + +**The blocking bridge's permit cannot be released in `doFinally`.** That fires on the subscriber's +cancel signal, which arrives while the callable is still blocked on a thread — releasing there hands +the permit to another caller while the first still holds the database connection it was accounting +for. Acquire and release are in the same `try`/`finally` inside the callable, and the consequence, +stated rather than hidden, is that cancellation does not interrupt a blocking call. + +On the servlet side the equivalent is that there is no disconnect event at all: the first sign a +client is gone is a write that throws, which is why the heartbeat is the probe rather than a +courtesy and why a failed beat is recorded as disconnect evidence. + +### One pre-existing flake was fixed + +`HttpThrottleFixture.awaitSlotTaken` polled for a 503 while each probe spent quota, and refilled the +quota only after the loop. On a machine loaded enough that the holding request took a while to +occupy the slot, the quota ran out first and every remaining probe answered 429 — so the loop never +saw its 503 and failed on the deadline, which reads as a capacity bug and is a fixture bug. It now +refills before every probe and fails loudly if a probe is refused for quota immediately after a +refill. Found because the new virtual-thread and blocking-bridge tests load the machine enough to +trigger it. + +### Verification + +```bash +cd src +./gradlew :adapter:inbound:web:test # 861 tests, Advanced included +./gradlew :adapter:inbound:web:webAdvancedTest # 147, the Advanced lane on its own +./gradlew :adapter:inbound:web:webCrossStackParityTest +./gradlew :adapter:inbound:web:webNginxProxyTest # Docker +``` + +See `docs/web/advanced-capabilities.md`, `docs/web/streaming-contract.md`, +`docs/web/patch-contract.md`, `docs/web/virtual-thread-profile.md`, +`docs/web/openapi-32-compatibility.md`, and `docs/adr/ADR-WEB-ADV-00{1,2,3}-*.md`. diff --git a/docs/web/runbook.md b/docs/web/runbook.md new file mode 100644 index 00000000..52091e3e --- /dev/null +++ b/docs/web/runbook.md @@ -0,0 +1,116 @@ +# Web platform runbook + +What an operator needs when the HTTP boundary misbehaves. Organised by what you observe, because +that is what you have at 3am — not by which module owns it. + +## Reading the two "too much traffic" answers + +The platform never answers these two interchangeably, and the difference tells you where to look. + +| You see | It means | Where to look | +| --- | --- | --- | +| `429 RATE_LIMITED` climbing for one caller | that caller exceeded its quota | the caller. Nothing is wrong with the service. | +| `429` climbing across **all** callers | quota is being charged for requests the service then shed | look at 503 first; see below | +| `503 ADMISSION_REJECTED` | the service has no capacity | saturation: check the admission profile's concurrency and what is holding slots | + +The second row is a real consequence of the design and worth knowing before it confuses you. Quota +is charged *before* capacity is requested, deliberately — the reverse order lets a caller who is +about to be rate-limited occupy a slot on the way to being told so. Under sustained overload every +caller's quota therefore drains on requests that never ran. **Rising 429 during an incident is a +symptom of shedding, not of callers misbehaving.** Read the 503 rate first. + +## A control that appears configured and does nothing + +This has happened here, and the failure is silent by construction. A configured-but-unwired control +behaves exactly like a working one until it is needed. + +- **Check first:** the platform snapshot's `uninstalledControls()`. `WebPlatformStartupValidator` + fails startup on a missing required control, so a running instance with one missing means it was + not in the required list. +- **The instance found the hard way:** the problem catalog was complete, fully unit-tested, and + reached by nothing on the framework's error path. Spring answered failures with its own + `ProblemDetail` — RFC 9457-shaped, so it looked correct — carrying no `code` field. It was found + by a cross-stack parity recording, not by any test of the catalog. +- **How to confirm quickly:** send a request that must fail validation and check the body has a + `code`. No `code` means the platform's handler is not installed. + +## A response is truncated or the connection dies mid-document + +Two different causes, distinguished by the status the client did receive. + +- **Client got a status, then nothing** — the response budget was crossed after commit. The + platform cannot retract a status, so it ends the connection: a truncated response the client + rejects is better than a short one it accepts as complete. Raise `maxResponseBytes` for that + operation's profile, or make the endpoint paginate. +- **Client got nothing at all** — either the request budget was crossed before headers, in which + case there is a problem document, or the proxy refused it. `client_max_body_size` in nginx is + below the application's body budget by default, and nginx answers with its own HTML rather than a + problem document. See `repository-adaptation.md` §8 for which bound fires where. + +## A retried write happened twice + +The idempotency key is the control, and there are exactly three ways it fails to apply. + +1. **The client did not send one.** The operation profile says `OPTIONAL`, so it ran unguarded. + Change the profile to `REQUIRED` if a duplicate is unacceptable. +2. **The client sent a different body.** Answered `422 IDEMPOTENCY_KEY_REUSED`, never a replay — + replaying would hand back a receipt for a request the caller never made. +3. **The record expired.** TTL is per-operation with a 72h cap. A retry after expiry is a new + request by definition. + +A `409 IDEMPOTENCY_REQUEST_IN_PROGRESS` is not a failure: an earlier attempt is still running and +the caller should retry after `Retry-After`. + +## Rolling deploy stalls with instances half-drained + +`shutDownGracefully` waits for in-flight work, and on Reactor Netty an open-but-idle keep-alive +connection counts as in-flight. Calling `stop()` while that wait is in progress hangs. + +- **Symptom:** an instance neither serving nor exiting, no error in its log. +- **Cause:** an unbounded graceful wait. `GracefulShutdownProbe` bounds it at a stated grace period + and stops the server regardless once it elapses; a deployment must do the same. +- **Setting:** `spring.lifecycle.timeout-per-shutdown-phase`. Without a bound the deploy waits for + a connection that may never go idle. + +## Metrics stopped arriving, or the bill jumped + +Almost always a high-cardinality tag. `WebMetricCardinalityPolicy` refuses anything off an +eight-name allowlist at the point of recording, so a new tag cannot appear by accident — but a +`routeTemplate` carrying a *resolved* path can, and that is one series per resource. + +- **Check:** the tag values in the metrics backend for `routeTemplate`. Braces mean templates; + identifiers mean the resolved path leaked through. +- **Never tags:** the URL, the query string, any identifier, the tenant, a key, a token, a cookie, + a body. A tag value reaches the metrics backend unredacted and usually a third-party SaaS with it. + +## Cross-origin requests fail only in the browser + +The API answers correctly and the browser refuses the response. Everything below is refused at +startup by `WebCorsPolicyValidator`, so a running instance with one of these means the profile was +built somewhere that does not validate. + +- `*` with credentials — no browser honours it. +- An origin with a path, a trailing slash, or uppercase — never matches what the browser sends. +- `https://*.example.com` — matches nothing; CORS compares origins exactly. +- Wildcard `allowedHeaders` on a credentialed profile — not honoured with credentials. + +A preflight answered `401` means CORS ran after authentication. Preflights carry no credentials by +design; the order is asserted by `WebPipelineOrderContract`. + +## Two access-log lines for one request + +An async request passes through the servlet filter chain twice — once for the initial request and +again on the ASYNC redispatch. A completion recorded without checking `isAsyncStarted()` is written +both times, and every latency percentile computed from that data is wrong while looking plausible. +`WebPipelineOrderContract` asserts exactly one observation per logical request on every container. + +## Verification commands + +```bash +cd src +./gradlew :adapter:inbound:web:test # unit, boundary, architecture +./gradlew :adapter:inbound:web:webCrossStackParityTest # Tomcat vs Jetty vs Reactor Netty +./gradlew :adapter:inbound:web:webNginxProxyTest # real proxy; needs Docker, fails without it +./gradlew :adapter:inbound:web:webJettyCompatTest # second servlet container +./gradlew :adapter:inbound:web:webFluxContractTest # reactive stack +``` diff --git a/docs/web/streaming-contract.md b/docs/web/streaming-contract.md new file mode 100644 index 00000000..bc0ada5d --- /dev/null +++ b/docs/web/streaming-contract.md @@ -0,0 +1,78 @@ +# Web streaming: the client contract + +What a client must implement to consume SSE, NDJSON or `application/json-seq` from this platform. + +## Three outcomes, not two + +After the first byte the HTTP status is 200 and cannot change. So the status tells you nothing about +whether the stream succeeded, and a closed connection is ambiguous. There are three endings: + +1. **A `Complete` envelope.** The stream finished. `lastSequence` is the final position. +2. **A `Failure` envelope.** Something failed after commit. Carries a `ProblemCode` and a + client-safe message. The status is still 200. +3. **Neither.** The connection closed mid-stream. The server records this as `ABRUPT_CLOSE`; from + the client's side it is indistinguishable from a completed stream *unless the client is looking + for the terminal envelope*. + +**A client that treats a closed connection as completion will silently truncate data.** No server +change can fix that for it. Looking for the terminal envelope is the contract. + +## Positions + +Every `Item` carries a `sequence`, counting from 1. Positions are strictly increasing, and the +server enforces it — a repeat or a regression is a server-side error, not something a client has to +tolerate. + +Zero is never a valid position. A stream whose first item claimed 0 and one whose sequence was never +set would look identical. + +## Resuming + +Where a route supports it, send the last position you applied as `Last-Event-ID`. + +- If the source still holds it, delivery continues from the next position. +- If it does not, the response is a **resnapshot-required** error, not a partial stream. Resuming + from the oldest retained position would give you contiguous positions with a hole in the middle, + and nothing in the data would say so. + +A resnapshot means re-reading the resource from its normal endpoint and starting a fresh stream. +Clients that cannot do that cheaply should not use the resume path. + +## Framing + +| Format | Media type | Framing | Truncation behaviour | +| --- | --- | --- | --- | +| SSE | `text/event-stream` | `data:` lines, blank-line separated | Partial event at the end | +| NDJSON | `application/x-ndjson` | One JSON value, then `\n` | The truncated line has no newline, and the delimiter is what was lost | +| JSON-seq | `application/json-seq` | `0x1E`, value, `\n` | The next `0x1E` unambiguously starts the next record | + +Prefer JSON-seq where truncation matters. Its separator comes *first*, so a parser resynchronises at +the next record rather than trying to parse the truncation joined to what follows. For a long-lived +stream, truncation is the normal way it ends. + +Records never contain a raw newline. The server refuses to write one, because for NDJSON the +consumer's line split is the only record boundary there is. + +## Heartbeats and timeouts + +The server writes a keepalive every `heartbeatInterval`. A client that sees nothing for longer than +that should assume the connection is dead — a silent connection and a disconnected one are the same +thing at the socket, and the heartbeat is what separates them. + +Streams are closed at `idleTimeout` (nothing produced) and at `maxStreamAge` (regardless of +activity). Both are normal endings, and both send a terminal envelope where the connection permits. + +## Slow consumers + +The server buffers at most `maxBufferedItems` for a consumer that is behind. Past that it closes the +connection rather than growing the buffer. A client that cannot keep up should reconnect with a +resume cursor, not expect the server to hold its backlog. + +## Shutdown + +During a rolling deploy, open streams receive a reconnect request before the node stops accepting. +Reconnect promptly — the node force-closes anything still open at its drain deadline, and that +arrives as an abrupt close. + +Do not reconnect immediately on an abrupt close without backoff and jitter. If a node's streams are +all cut at once, every client reconnecting at once is what keeps the replacement down. diff --git a/docs/web/virtual-thread-profile.md b/docs/web/virtual-thread-profile.md new file mode 100644 index 00000000..f35d3da7 --- /dev/null +++ b/docs/web/virtual-thread-profile.md @@ -0,0 +1,78 @@ +# The virtual-thread MVC profile + +## What it changes, and what it does not + +Virtual threads remove the cost of a thread waiting. They do not remove the reason the waiting was +bounded. + +A platform-thread MVC deployment has an implicit concurrency limit — the thread pool. Nobody wrote +it down as an admission policy, but it is what has been protecting the database pool, the outbound +HTTP bulkhead and every downstream service from the full arrival rate. + +Switching to virtual threads deletes that limit and deletes nothing that depended on it. The result +is not a slow system. It is a system that accepts ten thousand concurrent requests, queues all of +them on a twenty-connection pool, and times out every one — having done no useful work. The load +that used to be shed at the front door is shed at the back, after the cost of accepting it. + +## Enabling it + +Two settings, and the profile refuses to be constructed with only one: + +```yaml +backend: + web: + advanced: + mvc-virtual-threads: + enabled: true + admission-limit: 100 # required + database-pool-size: 20 # stated, and unchanged + outbound-bulkhead: 20 # stated, and unchanged +``` + +The downstream numbers are carried in the profile because the whole point is that they did not grow. +`VirtualThreadProfile.admissionFitsDownstreamBudgets()` reports when the admission limit exceeds +them. It does not refuse — a deployment can legitimately admit more than its pool when the work is +not all database-bound — but it makes the choice a choice. + +## The limit bounds use cases, not threads + +`VirtualThreadAdmissionGuard` is a fair semaphore, not a pool. Bounding the threads would put the +waiting back and throw away what virtual threads bought. Ten thousand virtual threads may exist +while a hundred hold permits and the rest are refused at the door. + +A refusal is a **503 with `Retry-After`**, and it is the outcome the limit exists to produce. A +request refused in a millisecond is strictly better for the client than the same request accepted +and timed out thirty seconds later behind a full pool. + +The semaphore is fair on purpose. An unfair one is faster and lets newer arrivals overtake waiting +ones, which the overtaken client experiences as a random timeout. + +## What to watch + +| Signal | Why | +| --- | --- | +| `jdk.VirtualThreadPinned` JFR events | A `synchronized` block held across a blocking call pins the carrier thread. The carrier pool is bounded by CPU count, so enough pinned carriers is a deadlock — and a thread dump does not obviously show it. | +| Carrier pool queue depth | The same problem, from the other side. | +| Admission rejections | They should rise under load. If they do not, the limit is not being applied. | +| Downstream wait time | The signal that admission is admitting more than the pools serve. | + +`VirtualThreadProfile.requiredObservations()` is the same list, in code. + +## Testing it + +`VirtualThreadAdmissionGuard.peakActive()` exists so a load test can assert the limit was applied. +It is invisible from throughput — a load test that only measures throughput passes with the guard +removed, which is precisely the failure this profile guards against. + +The test that matters asserts two things together: peak concurrency at or below the limit, **and** +more threads created than the limit. Without the second, the test would pass on a deployment that +never used virtual threads at all. + +## Rolling back + +Set `enabled: false`. The rollback test asserts that Stable behaviour is then identical — if it is +not, the feature was never optional and every deployment has it. + +This is one of only two web Advanced capabilities that affect requests which do not use them (the +other is the WebFlux blocking bridge), which is why its soak is 24 hours and its rollback test is +the one that matters most. diff --git a/docs/websocket-superpowers-package/MANIFEST.sha256 b/docs/websocket-superpowers-package/MANIFEST.sha256 new file mode 100644 index 00000000..9415c263 --- /dev/null +++ b/docs/websocket-superpowers-package/MANIFEST.sha256 @@ -0,0 +1,7 @@ +f145f6b13d665c52edd061695fd384825891ea6146946c81dae438c1cb9ee4c3 README.md +bceb2d92489db305aa07f9f6a51fe6cb40e9971cee600275887192caff220e31 VALIDATION.md +fdc801db5190213d213eb59e16a289acd22096bcdba4cd1b3e89ca98a1af45a2 docs/superpowers/plans/2026-08-14-websocket-advanced-capabilities-expansion-plan.md +97969421447fc3b54ca5b18baacd13e84ebad1e2c8ab054a977e488daba02e4c docs/superpowers/plans/2026-08-14-websocket-realtime-connection-platform-implementation-plan.md +8a49c95ef8bec0312ca028f80302332ef811c12d578ff8a89b7843c966f44ff9 docs/superpowers/specs/2026-08-14-websocket-realtime-connection-platform-design.md +57dedc7fd8e6f5de96a61a7295a1d4b474507cdd9029760e1907f5f47abc6de8 research/source-websocket-deep-research.md +459a713434fa74c7182b947fdf79d6b89a7aa9c7f5070d26903744bc722be5b1 validate_websocket_docs.py diff --git a/docs/websocket-superpowers-package/README.md b/docs/websocket-superpowers-package/README.md new file mode 100644 index 00000000..3cc4a682 --- /dev/null +++ b/docs/websocket-superpowers-package/README.md @@ -0,0 +1,28 @@ +# WebSocket Superpowers Package + +이 패키지는 WebSocket 실시간 양방향 연결 실행 플랫폼의 설계서, Stable 구현 계획, Advanced 확장 계획, 요구사항 원본과 정적 검증 도구를 포함한다. + +## 적용 순서 + +```text +Stable Task 1–53 +→ Stable Release Gate +→ Advanced Task 1–22 +→ 기능별 Promotion Gate +``` + +## 문서 + +- `docs/superpowers/specs/2026-08-14-websocket-realtime-connection-platform-design.md` +- `docs/superpowers/plans/2026-08-14-websocket-realtime-connection-platform-implementation-plan.md` +- `docs/superpowers/plans/2026-08-14-websocket-advanced-capabilities-expansion-plan.md` +- `research/source-websocket-deep-research.md` + +## 검증 + +```bash +python validate_websocket_docs.py +sha256sum -c MANIFEST.sha256 +``` + +이 검증은 문서 구조·계약 일관성·패키지 무결성 검증이며 실제 Gradle compile, Browser, Nginx, Container, Fault, Performance 실행을 대체하지 않는다. diff --git a/docs/websocket-superpowers-package/VALIDATION.md b/docs/websocket-superpowers-package/VALIDATION.md new file mode 100644 index 00000000..b1980389 --- /dev/null +++ b/docs/websocket-superpowers-package/VALIDATION.md @@ -0,0 +1,47 @@ +# WebSocket Superpowers 문서 정적 검증 결과 + +- **검증일:** 2026-08-14 +- **검증 명령:** `python validate_websocket_docs.py` +- **검증 출력:** `checks=719 passed=719 failed=0` +- **결과:** PASS + +## 문서 규모 + +| 문서 | 행 | Task | Create 경로 | +|---|---:|---:|---:| +| 설계서 | 2,810 | - | - | +| Stable 구현 계획 | 4,293 | 53 | 121 | +| Advanced 확장 계획 | 1,817 | 22 | 48 | + +## 검증 항목 + +- Stable Task 1–53 번호 연속성 +- Advanced Task 1–22 번호 연속성 +- 모든 Task의 `Files`, `Interfaces`, `Implementation requirements`, Step 1–5, commit 명령 +- Stable·Advanced Create 경로 중복 및 충돌 부재 +- `TODO`, `TBD`, `FIXME` placeholder 부재 +- Markdown code fence 균형 +- Stable Raw Typed JSON·Evidence·Ticket·Queue·Runtime·Nginx·Browser 계약 포함 +- Advanced Resume·Cluster·STOMP·Broker Relay·Binary·Compression·HTTP/2·3 계약 포함 +- 요구사항 원본 Appendix 및 research file 보존 +- SHA-256 manifest와 ZIP CRC 검증 가능 구조 + +## 검증 범위의 한계 + +현재 검증은 설계서와 구현 계획서의 정적 구조·내부 계약·패키지 무결성 검증이다. 실제 Backend Skeleton 저장소가 제공되지 않았으므로 다음은 실행하지 않았다. + +```text +Gradle configuration·compile +Spring Boot ApplicationContext +Tomcat·Jetty·Reactor Netty WebSocket contract +Nginx TLS Upgrade path +Chromium·Firefox·WebKit browser matrix +Redis ticket·session index integration +JPA result ledger transaction +Messaging replay/fan-out +Commit 후 socket reset fault +Slow consumer·memory·latency performance +STOMP·RabbitMQ broker relay +HTTP/2·HTTP/3 compatibility +Git commit +``` diff --git a/docs/websocket-superpowers-package/docs/superpowers/plans/2026-08-14-websocket-advanced-capabilities-expansion-plan.md b/docs/websocket-superpowers-package/docs/superpowers/plans/2026-08-14-websocket-advanced-capabilities-expansion-plan.md new file mode 100644 index 00000000..2680dd4a --- /dev/null +++ b/docs/websocket-superpowers-package/docs/superpowers/plans/2026-08-14-websocket-advanced-capabilities-expansion-plan.md @@ -0,0 +1,1817 @@ +# WebSocket Advanced Capability Expansion Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Stable Raw Typed WebSocket의 security·evidence·budget·writer 계약을 유지하면서 durable resume, multi-node fan-out, STOMP/Broker Relay, Binary Codec, compression, outbound client, SockJS와 HTTP/2·3 compatibility를 선택적으로 추가한다. + +**Architecture:** 모든 Advanced 모듈은 `modules/websocket-advanced`에 격리되고 Stable public contract를 소비한다. Resume history와 durable cross-node delivery는 Messaging Platform이 소유하며 Redis는 session index·presence·ephemeral fan-out에만 사용한다. STOMP는 별도 Protocol Adapter이고 RECEIPT·ACK·destination 의미를 Stable Raw evidence로 평탄화하지 않는다. + +**Tech Stack:** Java 21, Spring Boot 4.1 BOM, Stable WebSocket modules, Spring Messaging/STOMP, RabbitMQ STOMP relay, Redis, Messaging Platform, Protobuf, Jackson CBOR, RFC 7692, Playwright, Nginx/Ingress HTTP/2·3 test path. + +## Global Constraints + +- Stable Task 1–53과 Stable Release Gate가 완료된 뒤 시작한다. +- Stable Starter는 Advanced module을 의존하지 않는다. +- 모든 Advanced 기능은 `backend.websocket.advanced.*` feature flag를 요구한다. +- Advanced 기능도 Stable Origin·Authentication·Evidence·Budget·Writer·Observability 계약을 우회하지 않는다. +- durable replay·offset·DLQ는 Messaging이 소유한다. +- Redis Pub/Sub을 durable replay라고 선언하지 않는다. +- actual transport session을 Redis에 직렬화하지 않는다. +- STOMP RECEIPT을 Application Commit으로 승격하지 않는다. +- STOMP ACK을 보편적 exactly-once로 표현하지 않는다. +- Simple Broker는 Local/Test 단일 node에 제한한다. +- Protobuf·CBOR도 대형 file bytes transport를 제공하지 않는다. +- `permessage-deflate`는 기본 비활성이고 endpoint별 benchmark 후 opt-in한다. +- HTTP/2는 Compatibility, HTTP/3은 Experimental 등급을 유지한다. +- GraphQL bridge는 GraphQL operation·error·subscription semantics를 소유하지 않는다. +- 모든 task는 red-green TDD와 독립 commit으로 끝난다. + +--- + +## Execution Baseline + +```text +Stable Task 1–53 PASS +→ Advanced Task 1–22 +→ 기능별 Promotion Gate +``` + +--- +### Task 1: Advanced 모듈 그래프와 Feature Flag 격리 + + **Files:** + - Create: `modules/websocket-advanced/websocket-advanced-bootstrap/src/main/java/io/backend/skeleton/websocket/advanced/WebSocketAdvancedModuleCatalog.java` +- Create: `modules/websocket-advanced/build.gradle.kts` +- Create: `modules/websocket-advanced/websocket-advanced-bootstrap/build.gradle.kts` +- Create: `modules/websocket-advanced/websocket-resume/build.gradle.kts` +- Create: `modules/websocket-advanced/websocket-cluster-redis/build.gradle.kts` +- Create: `modules/websocket-advanced/websocket-cluster-messaging/build.gradle.kts` +- Create: `modules/websocket-advanced/websocket-presence-redis/build.gradle.kts` +- Create: `modules/websocket-advanced/websocket-stomp/build.gradle.kts` +- Create: `modules/websocket-advanced/websocket-broker-relay-rabbit/build.gradle.kts` +- Create: `modules/websocket-advanced/websocket-protobuf/build.gradle.kts` +- Create: `modules/websocket-advanced/websocket-cbor/build.gradle.kts` +- Create: `modules/websocket-advanced/websocket-compression/build.gradle.kts` +- Create: `modules/websocket-advanced/websocket-outbound-client/build.gradle.kts` +- Create: `modules/websocket-advanced/websocket-sockjs-compat/build.gradle.kts` +- Create: `modules/websocket-advanced/websocket-http2-compat/build.gradle.kts` +- Create: `modules/websocket-advanced/websocket-http3-experimental/build.gradle.kts` +- Create: `modules/websocket-advanced/websocket-graphql-transport-bridge/build.gradle.kts` +- Test: `modules/websocket-advanced/websocket-advanced-bootstrap/src/test/java/io/backend/skeleton/websocket/advanced/WebSocketAdvancedModuleCatalogTest.java` + + **Interfaces:** + - Consumes: Stable Task 1–53 Release Gate. + - Produces: Advanced dependency graph와 `backend.websocket.advanced.*` opt-in flags. + + **Implementation requirements:** + - Stable starter가 advanced module을 의존하지 않는다. +- 모든 advanced bean은 explicit feature flag를 요구한다. +- Stable public evidence·security·budget contract를 우회하지 않는다. + + - [ ] **Step 1: Write the failing test** + + ```java + package io.backend.skeleton.websocket.advanced; + +class WebSocketAdvancedModuleCatalogTest { + @org.junit.jupiter.api.Test + void recordShapeIsStable() { + var names = java.util.Arrays.stream(WebSocketAdvancedModuleCatalog.class.getRecordComponents()) + .map(java.lang.reflect.RecordComponent::getName) + .toList(); + org.assertj.core.api.Assertions.assertThat(names) + .containsExactly("modules", "featureFlags"); + } +} + ``` + + - [ ] **Step 2: Run the focused test and verify the failure** + + Run: + + ```bash + ./gradlew :modules:websocket-advanced:websocket-advanced-bootstrap:test --tests 'io.backend.skeleton.websocket.advanced.WebSocketAdvancedModuleCatalogTest' + ``` + + Expected: FAIL because `WebSocketAdvancedModuleCatalog` and its required policy contract do not exist yet. + + - [ ] **Step 3: Implement the smallest complete production contract** + + ```java + package io.backend.skeleton.websocket.advanced; + +public record WebSocketAdvancedModuleCatalog( + java.util.Set modules, + java.util.Set featureFlags) { + public WebSocketAdvancedModuleCatalog { + java.util.Objects.requireNonNull(modules, "modules"); + java.util.Objects.requireNonNull(featureFlags, "featureFlags"); + } +} + ``` + + Implement every listed production file with the exact public names, boundaries and invariants above. Keep raw transport sessions, credentials, payloads, database types and dynamic identifiers out of the public contract unless this task explicitly defines a bounded adapter type. + + - [ ] **Step 4: Run the focused test and the owning suite** + + Run: + + ```bash + ./gradlew :modules:websocket-advanced:websocket-advanced-bootstrap:test --tests 'io.backend.skeleton.websocket.advanced.WebSocketAdvancedModuleCatalogTest' + ./gradlew websocketAdvancedTest + ``` + + Expected: PASS for the focused test and the aggregate Advanced suite. + + - [ ] **Step 5: Commit the independently reviewable change** + + ```bash + git add 'modules/websocket-advanced/websocket-advanced-bootstrap/src/main/java/io/backend/skeleton/websocket/advanced/WebSocketAdvancedModuleCatalog.java' 'modules/websocket-advanced/websocket-advanced-bootstrap/src/test/java/io/backend/skeleton/websocket/advanced/WebSocketAdvancedModuleCatalogTest.java' 'modules/websocket-advanced/build.gradle.kts' 'modules/websocket-advanced/websocket-advanced-bootstrap/build.gradle.kts' 'modules/websocket-advanced/websocket-resume/build.gradle.kts' 'modules/websocket-advanced/websocket-cluster-redis/build.gradle.kts' 'modules/websocket-advanced/websocket-cluster-messaging/build.gradle.kts' 'modules/websocket-advanced/websocket-presence-redis/build.gradle.kts' 'modules/websocket-advanced/websocket-stomp/build.gradle.kts' 'modules/websocket-advanced/websocket-broker-relay-rabbit/build.gradle.kts' 'modules/websocket-advanced/websocket-protobuf/build.gradle.kts' 'modules/websocket-advanced/websocket-cbor/build.gradle.kts' 'modules/websocket-advanced/websocket-compression/build.gradle.kts' 'modules/websocket-advanced/websocket-outbound-client/build.gradle.kts' 'modules/websocket-advanced/websocket-sockjs-compat/build.gradle.kts' 'modules/websocket-advanced/websocket-http2-compat/build.gradle.kts' 'modules/websocket-advanced/websocket-http3-experimental/build.gradle.kts' 'modules/websocket-advanced/websocket-graphql-transport-bridge/build.gradle.kts' + git commit -m "build: advanced-feature-flag" + ``` + +### Task 2: Resume Token Wire Contract + + **Files:** + - Create: `modules/websocket-advanced/websocket-resume/src/main/java/io/backend/skeleton/websocket/resume/WebSocketResumeTokenPayload.java` +- Create: `modules/websocket-advanced/websocket-resume/src/main/java/io/backend/skeleton/websocket/resume/WebSocketResumeTokenCodec.java` +- Create: `modules/websocket-advanced/websocket-resume/src/main/java/io/backend/skeleton/websocket/resume/WebSocketResumeTokenKeyRing.java` +- Test: `modules/websocket-advanced/websocket-resume/src/test/java/io/backend/skeleton/websocket/resume/WebSocketResumeTokenPayloadTest.java` + + **Interfaces:** + - Consumes: Stable sequence·connection context. + - Produces: versioned·HMAC authenticated·actor/tenant bound resume token. + + **Implementation requirements:** + - lastReceived가 아니라 lastApplied를 사용한다. +- token은 actor·tenant·endpoint·protocol에 bind한다. +- unknown key/version·expired·replay를 구분한다. + + - [ ] **Step 1: Write the failing test** + + ```java + package io.backend.skeleton.websocket.resume; + +class WebSocketResumeTokenPayloadTest { + @org.junit.jupiter.api.Test + void recordShapeIsStable() { + var names = java.util.Arrays.stream(WebSocketResumeTokenPayload.class.getRecordComponents()) + .map(java.lang.reflect.RecordComponent::getName) + .toList(); + org.assertj.core.api.Assertions.assertThat(names) + .containsExactly("streamId", "lastAppliedSequence", "snapshotVersion", "expiresAt", "keyId"); + } +} + ``` + + - [ ] **Step 2: Run the focused test and verify the failure** + + Run: + + ```bash + ./gradlew :modules:websocket-advanced:websocket-resume:test --tests 'io.backend.skeleton.websocket.resume.WebSocketResumeTokenPayloadTest' + ``` + + Expected: FAIL because `WebSocketResumeTokenPayload` and its required policy contract do not exist yet. + + - [ ] **Step 3: Implement the smallest complete production contract** + + ```java + package io.backend.skeleton.websocket.resume; + +public record WebSocketResumeTokenPayload( + String streamId, + long lastAppliedSequence, + String snapshotVersion, + java.time.Instant expiresAt, + String keyId) { + public WebSocketResumeTokenPayload { + java.util.Objects.requireNonNull(streamId, "streamId"); + java.util.Objects.requireNonNull(snapshotVersion, "snapshotVersion"); + java.util.Objects.requireNonNull(expiresAt, "expiresAt"); + java.util.Objects.requireNonNull(keyId, "keyId"); + } +} + ``` + + Implement every listed production file with the exact public names, boundaries and invariants above. Keep raw transport sessions, credentials, payloads, database types and dynamic identifiers out of the public contract unless this task explicitly defines a bounded adapter type. + + - [ ] **Step 4: Run the focused test and the owning suite** + + Run: + + ```bash + ./gradlew :modules:websocket-advanced:websocket-resume:test --tests 'io.backend.skeleton.websocket.resume.WebSocketResumeTokenPayloadTest' + ./gradlew websocketAdvancedTest + ``` + + Expected: PASS for the focused test and the aggregate Advanced suite. + + - [ ] **Step 5: Commit the independently reviewable change** + + ```bash + git add 'modules/websocket-advanced/websocket-resume/src/main/java/io/backend/skeleton/websocket/resume/WebSocketResumeTokenPayload.java' 'modules/websocket-advanced/websocket-resume/src/test/java/io/backend/skeleton/websocket/resume/WebSocketResumeTokenPayloadTest.java' 'modules/websocket-advanced/websocket-resume/src/main/java/io/backend/skeleton/websocket/resume/WebSocketResumeTokenCodec.java' 'modules/websocket-advanced/websocket-resume/src/main/java/io/backend/skeleton/websocket/resume/WebSocketResumeTokenKeyRing.java' + git commit -m "feat: resume-token-wire-contract" + ``` + +### Task 3: Resume Coordinator와 Snapshot Fallback + + **Files:** + - Create: `modules/websocket-advanced/websocket-resume/src/main/java/io/backend/skeleton/websocket/resume/WebSocketResumeDecision.java` +- Create: `modules/websocket-advanced/websocket-resume/src/main/java/io/backend/skeleton/websocket/resume/WebSocketResumeCoordinator.java` +- Create: `modules/websocket-advanced/websocket-resume/src/main/java/io/backend/skeleton/websocket/resume/WebSocketSnapshotProvider.java` +- Test: `modules/websocket-advanced/websocket-resume/src/test/java/io/backend/skeleton/websocket/resume/WebSocketResumeDecisionTest.java` + + **Interfaces:** + - Consumes: Resume token과 sequence gap detector. + - Produces: delta replay·snapshot fallback·permission check orchestration. + + **Implementation requirements:** + - history loss에서 incremental apply를 계속하지 않는다. +- permission changed 시 resume를 거부한다. +- snapshot 이후 새 sequence 기준을 명시한다. + + - [ ] **Step 1: Write the failing test** + + ```java + package io.backend.skeleton.websocket.resume; + +class WebSocketResumeDecisionTest { + @org.junit.jupiter.api.Test + void valuesAreStable() { + org.assertj.core.api.Assertions.assertThat(WebSocketResumeDecision.values()) + .extracting(java.lang.Enum::name) + .containsExactly("REPLAY_AVAILABLE", "SNAPSHOT_REQUIRED", "RESUME_DENIED", "RESUME_EXPIRED", "STREAM_GONE"); + } +} + ``` + + - [ ] **Step 2: Run the focused test and verify the failure** + + Run: + + ```bash + ./gradlew :modules:websocket-advanced:websocket-resume:test --tests 'io.backend.skeleton.websocket.resume.WebSocketResumeDecisionTest' + ``` + + Expected: FAIL because `WebSocketResumeDecision` and its required policy contract do not exist yet. + + - [ ] **Step 3: Implement the smallest complete production contract** + + ```java + package io.backend.skeleton.websocket.resume; + +public enum WebSocketResumeDecision { + REPLAY_AVAILABLE, + SNAPSHOT_REQUIRED, + RESUME_DENIED, + RESUME_EXPIRED, + STREAM_GONE +} + ``` + + Implement every listed production file with the exact public names, boundaries and invariants above. Keep raw transport sessions, credentials, payloads, database types and dynamic identifiers out of the public contract unless this task explicitly defines a bounded adapter type. + + - [ ] **Step 4: Run the focused test and the owning suite** + + Run: + + ```bash + ./gradlew :modules:websocket-advanced:websocket-resume:test --tests 'io.backend.skeleton.websocket.resume.WebSocketResumeDecisionTest' + ./gradlew websocketAdvancedTest + ``` + + Expected: PASS for the focused test and the aggregate Advanced suite. + + - [ ] **Step 5: Commit the independently reviewable change** + + ```bash + git add 'modules/websocket-advanced/websocket-resume/src/main/java/io/backend/skeleton/websocket/resume/WebSocketResumeDecision.java' 'modules/websocket-advanced/websocket-resume/src/test/java/io/backend/skeleton/websocket/resume/WebSocketResumeDecisionTest.java' 'modules/websocket-advanced/websocket-resume/src/main/java/io/backend/skeleton/websocket/resume/WebSocketResumeCoordinator.java' 'modules/websocket-advanced/websocket-resume/src/main/java/io/backend/skeleton/websocket/resume/WebSocketSnapshotProvider.java' + git commit -m "feat: resume-coordinator-snapshot-fallback" + ``` + +### Task 4: Messaging 기반 Durable Replay Bridge + + **Files:** + - Create: `modules/websocket-advanced/websocket-cluster-messaging/src/main/java/io/backend/skeleton/websocket/cluster/messaging/WebSocketReplayCursor.java` +- Create: `modules/websocket-advanced/websocket-cluster-messaging/src/main/java/io/backend/skeleton/websocket/cluster/messaging/WebSocketDurableReplaySource.java` +- Create: `modules/websocket-advanced/websocket-cluster-messaging/src/main/java/io/backend/skeleton/websocket/cluster/messaging/WebSocketReplayEventMapper.java` +- Test: `modules/websocket-advanced/websocket-cluster-messaging/src/test/java/io/backend/skeleton/websocket/cluster/messaging/WebSocketReplayCursorTest.java` + + **Interfaces:** + - Consumes: Messaging Platform replay/offset API와 Resume Coordinator. + - Produces: durable event history를 typed WebSocket event로 변환하는 bridge. + + **Implementation requirements:** + - WebSocket이 broker offset ownership을 가져가지 않는다. +- 원본 broker message를 client wire contract로 직접 노출하지 않는다. +- duplicate·gap·schema mismatch를 검증한다. + + - [ ] **Step 1: Write the failing test** + + ```java + package io.backend.skeleton.websocket.cluster.messaging; + +class WebSocketReplayCursorTest { + @org.junit.jupiter.api.Test + void recordShapeIsStable() { + var names = java.util.Arrays.stream(WebSocketReplayCursor.class.getRecordComponents()) + .map(java.lang.reflect.RecordComponent::getName) + .toList(); + org.assertj.core.api.Assertions.assertThat(names) + .containsExactly("streamId", "sequence", "messagingPosition"); + } +} + ``` + + - [ ] **Step 2: Run the focused test and verify the failure** + + Run: + + ```bash + ./gradlew :modules:websocket-advanced:websocket-cluster-messaging:test --tests 'io.backend.skeleton.websocket.cluster.messaging.WebSocketReplayCursorTest' + ``` + + Expected: FAIL because `WebSocketReplayCursor` and its required policy contract do not exist yet. + + - [ ] **Step 3: Implement the smallest complete production contract** + + ```java + package io.backend.skeleton.websocket.cluster.messaging; + +public record WebSocketReplayCursor( + String streamId, + long sequence, + String messagingPosition) { + public WebSocketReplayCursor { + java.util.Objects.requireNonNull(streamId, "streamId"); + java.util.Objects.requireNonNull(messagingPosition, "messagingPosition"); + } +} + ``` + + Implement every listed production file with the exact public names, boundaries and invariants above. Keep raw transport sessions, credentials, payloads, database types and dynamic identifiers out of the public contract unless this task explicitly defines a bounded adapter type. + + - [ ] **Step 4: Run the focused test and the owning suite** + + Run: + + ```bash + ./gradlew :modules:websocket-advanced:websocket-cluster-messaging:test --tests 'io.backend.skeleton.websocket.cluster.messaging.WebSocketReplayCursorTest' + ./gradlew websocketAdvancedTest + ``` + + Expected: PASS for the focused test and the aggregate Advanced suite. + + - [ ] **Step 5: Commit the independently reviewable change** + + ```bash + git add 'modules/websocket-advanced/websocket-cluster-messaging/src/main/java/io/backend/skeleton/websocket/cluster/messaging/WebSocketReplayCursor.java' 'modules/websocket-advanced/websocket-cluster-messaging/src/test/java/io/backend/skeleton/websocket/cluster/messaging/WebSocketReplayCursorTest.java' 'modules/websocket-advanced/websocket-cluster-messaging/src/main/java/io/backend/skeleton/websocket/cluster/messaging/WebSocketDurableReplaySource.java' 'modules/websocket-advanced/websocket-cluster-messaging/src/main/java/io/backend/skeleton/websocket/cluster/messaging/WebSocketReplayEventMapper.java' + git commit -m "feat: messaging-durable-replay-bridge" + ``` + +### Task 5: Redis External Session Index + + **Files:** + - Create: `modules/websocket-advanced/websocket-cluster-redis/src/main/java/io/backend/skeleton/websocket/cluster/redis/WebSocketExternalSessionSummary.java` +- Create: `modules/websocket-advanced/websocket-cluster-redis/src/main/java/io/backend/skeleton/websocket/cluster/redis/WebSocketRedisSessionIndex.java` +- Test: `modules/websocket-advanced/websocket-cluster-redis/src/test/java/io/backend/skeleton/websocket/cluster/redis/WebSocketExternalSessionSummaryTest.java` + + **Interfaces:** + - Consumes: Stable local registry와 Redis TTL capability. + - Produces: 실제 session object 없는 TTL external index. + + **Implementation requirements:** + - transport session을 Redis에 직렬화하지 않는다. +- stale entry를 TTL과 node heartbeat로 제거한다. +- actor/tenant 원문을 key에 넣지 않는다. + + - [ ] **Step 1: Write the failing test** + + ```java + package io.backend.skeleton.websocket.cluster.redis; + +class WebSocketExternalSessionSummaryTest { + @org.junit.jupiter.api.Test + void recordShapeIsStable() { + var names = java.util.Arrays.stream(WebSocketExternalSessionSummary.class.getRecordComponents()) + .map(java.lang.reflect.RecordComponent::getName) + .toList(); + org.assertj.core.api.Assertions.assertThat(names) + .containsExactly("connectionId", "nodeId", "actorFingerprint", "endpointName", "lastObservedAt"); + } +} + ``` + + - [ ] **Step 2: Run the focused test and verify the failure** + + Run: + + ```bash + ./gradlew :modules:websocket-advanced:websocket-cluster-redis:test --tests 'io.backend.skeleton.websocket.cluster.redis.WebSocketExternalSessionSummaryTest' + ``` + + Expected: FAIL because `WebSocketExternalSessionSummary` and its required policy contract do not exist yet. + + - [ ] **Step 3: Implement the smallest complete production contract** + + ```java + package io.backend.skeleton.websocket.cluster.redis; + +public record WebSocketExternalSessionSummary( + String connectionId, + String nodeId, + String actorFingerprint, + String endpointName, + java.time.Instant lastObservedAt) { + public WebSocketExternalSessionSummary { + java.util.Objects.requireNonNull(connectionId, "connectionId"); + java.util.Objects.requireNonNull(nodeId, "nodeId"); + java.util.Objects.requireNonNull(actorFingerprint, "actorFingerprint"); + java.util.Objects.requireNonNull(endpointName, "endpointName"); + java.util.Objects.requireNonNull(lastObservedAt, "lastObservedAt"); + } +} + ``` + + Implement every listed production file with the exact public names, boundaries and invariants above. Keep raw transport sessions, credentials, payloads, database types and dynamic identifiers out of the public contract unless this task explicitly defines a bounded adapter type. + + - [ ] **Step 4: Run the focused test and the owning suite** + + Run: + + ```bash + ./gradlew :modules:websocket-advanced:websocket-cluster-redis:test --tests 'io.backend.skeleton.websocket.cluster.redis.WebSocketExternalSessionSummaryTest' + ./gradlew websocketAdvancedTest + ``` + + Expected: PASS for the focused test and the aggregate Advanced suite. + + - [ ] **Step 5: Commit the independently reviewable change** + + ```bash + git add 'modules/websocket-advanced/websocket-cluster-redis/src/main/java/io/backend/skeleton/websocket/cluster/redis/WebSocketExternalSessionSummary.java' 'modules/websocket-advanced/websocket-cluster-redis/src/test/java/io/backend/skeleton/websocket/cluster/redis/WebSocketExternalSessionSummaryTest.java' 'modules/websocket-advanced/websocket-cluster-redis/src/main/java/io/backend/skeleton/websocket/cluster/redis/WebSocketRedisSessionIndex.java' + git commit -m "feat: redis-external-session-index" + ``` + +### Task 6: Redis Ephemeral Fan-out + + **Files:** + - Create: `modules/websocket-advanced/websocket-cluster-redis/src/main/java/io/backend/skeleton/websocket/cluster/redis/WebSocketEphemeralFanoutMessage.java` +- Create: `modules/websocket-advanced/websocket-cluster-redis/src/main/java/io/backend/skeleton/websocket/cluster/redis/WebSocketRedisEphemeralFanout.java` +- Test: `modules/websocket-advanced/websocket-cluster-redis/src/test/java/io/backend/skeleton/websocket/cluster/redis/WebSocketEphemeralFanoutMessageTest.java` + + **Interfaces:** + - Consumes: Redis Pub/Sub at-most-once capability와 external session index. + - Produces: lossy live signal fan-out adapter. + + **Implementation requirements:** + - durable replay라고 선언하지 않는다. +- 업무 payload 대신 bounded reference/event DTO를 사용한다. +- expiresAt 이후 fan-out을 drop한다. + + - [ ] **Step 1: Write the failing test** + + ```java + package io.backend.skeleton.websocket.cluster.redis; + +class WebSocketEphemeralFanoutMessageTest { + @org.junit.jupiter.api.Test + void recordShapeIsStable() { + var names = java.util.Arrays.stream(WebSocketEphemeralFanoutMessage.class.getRecordComponents()) + .map(java.lang.reflect.RecordComponent::getName) + .toList(); + org.assertj.core.api.Assertions.assertThat(names) + .containsExactly("targetNodeId", "messageType", "payloadReference", "expiresAt"); + } +} + ``` + + - [ ] **Step 2: Run the focused test and verify the failure** + + Run: + + ```bash + ./gradlew :modules:websocket-advanced:websocket-cluster-redis:test --tests 'io.backend.skeleton.websocket.cluster.redis.WebSocketEphemeralFanoutMessageTest' + ``` + + Expected: FAIL because `WebSocketEphemeralFanoutMessage` and its required policy contract do not exist yet. + + - [ ] **Step 3: Implement the smallest complete production contract** + + ```java + package io.backend.skeleton.websocket.cluster.redis; + +public record WebSocketEphemeralFanoutMessage( + String targetNodeId, + String messageType, + String payloadReference, + java.time.Instant expiresAt) { + public WebSocketEphemeralFanoutMessage { + java.util.Objects.requireNonNull(targetNodeId, "targetNodeId"); + java.util.Objects.requireNonNull(messageType, "messageType"); + java.util.Objects.requireNonNull(payloadReference, "payloadReference"); + java.util.Objects.requireNonNull(expiresAt, "expiresAt"); + } +} + ``` + + Implement every listed production file with the exact public names, boundaries and invariants above. Keep raw transport sessions, credentials, payloads, database types and dynamic identifiers out of the public contract unless this task explicitly defines a bounded adapter type. + + - [ ] **Step 4: Run the focused test and the owning suite** + + Run: + + ```bash + ./gradlew :modules:websocket-advanced:websocket-cluster-redis:test --tests 'io.backend.skeleton.websocket.cluster.redis.WebSocketEphemeralFanoutMessageTest' + ./gradlew websocketAdvancedTest + ``` + + Expected: PASS for the focused test and the aggregate Advanced suite. + + - [ ] **Step 5: Commit the independently reviewable change** + + ```bash + git add 'modules/websocket-advanced/websocket-cluster-redis/src/main/java/io/backend/skeleton/websocket/cluster/redis/WebSocketEphemeralFanoutMessage.java' 'modules/websocket-advanced/websocket-cluster-redis/src/test/java/io/backend/skeleton/websocket/cluster/redis/WebSocketEphemeralFanoutMessageTest.java' 'modules/websocket-advanced/websocket-cluster-redis/src/main/java/io/backend/skeleton/websocket/cluster/redis/WebSocketRedisEphemeralFanout.java' + git commit -m "feat: redis-ephemeral-fan-out" + ``` + +### Task 7: Messaging Durable Cross-node Fan-out + + **Files:** + - Create: `modules/websocket-advanced/websocket-cluster-messaging/src/main/java/io/backend/skeleton/websocket/cluster/messaging/WebSocketDurableFanoutEnvelope.java` +- Create: `modules/websocket-advanced/websocket-cluster-messaging/src/main/java/io/backend/skeleton/websocket/cluster/messaging/WebSocketMessagingFanoutAdapter.java` +- Test: `modules/websocket-advanced/websocket-cluster-messaging/src/test/java/io/backend/skeleton/websocket/cluster/messaging/WebSocketDurableFanoutEnvelopeTest.java` + + **Interfaces:** + - Consumes: Messaging publish/consumer contract와 local registry. + - Produces: durable fan-out source를 current sessions로 전달하는 adapter. + + **Implementation requirements:** + - broker ACK와 client ACK를 구분한다. +- consumer redelivery에서 same sequence duplicate를 처리한다. +- local outbound queue overflow 시 source ACK 정책을 명시한다. + + - [ ] **Step 1: Write the failing test** + + ```java + package io.backend.skeleton.websocket.cluster.messaging; + +class WebSocketDurableFanoutEnvelopeTest { + @org.junit.jupiter.api.Test + void recordShapeIsStable() { + var names = java.util.Arrays.stream(WebSocketDurableFanoutEnvelope.class.getRecordComponents()) + .map(java.lang.reflect.RecordComponent::getName) + .toList(); + org.assertj.core.api.Assertions.assertThat(names) + .containsExactly("targetSelector", "messageType", "streamId", "sequence"); + } +} + ``` + + - [ ] **Step 2: Run the focused test and verify the failure** + + Run: + + ```bash + ./gradlew :modules:websocket-advanced:websocket-cluster-messaging:test --tests 'io.backend.skeleton.websocket.cluster.messaging.WebSocketDurableFanoutEnvelopeTest' + ``` + + Expected: FAIL because `WebSocketDurableFanoutEnvelope` and its required policy contract do not exist yet. + + - [ ] **Step 3: Implement the smallest complete production contract** + + ```java + package io.backend.skeleton.websocket.cluster.messaging; + +public record WebSocketDurableFanoutEnvelope( + String targetSelector, + String messageType, + String streamId, + long sequence) { + public WebSocketDurableFanoutEnvelope { + java.util.Objects.requireNonNull(targetSelector, "targetSelector"); + java.util.Objects.requireNonNull(messageType, "messageType"); + java.util.Objects.requireNonNull(streamId, "streamId"); + } +} + ``` + + Implement every listed production file with the exact public names, boundaries and invariants above. Keep raw transport sessions, credentials, payloads, database types and dynamic identifiers out of the public contract unless this task explicitly defines a bounded adapter type. + + - [ ] **Step 4: Run the focused test and the owning suite** + + Run: + + ```bash + ./gradlew :modules:websocket-advanced:websocket-cluster-messaging:test --tests 'io.backend.skeleton.websocket.cluster.messaging.WebSocketDurableFanoutEnvelopeTest' + ./gradlew websocketAdvancedTest + ``` + + Expected: PASS for the focused test and the aggregate Advanced suite. + + - [ ] **Step 5: Commit the independently reviewable change** + + ```bash + git add 'modules/websocket-advanced/websocket-cluster-messaging/src/main/java/io/backend/skeleton/websocket/cluster/messaging/WebSocketDurableFanoutEnvelope.java' 'modules/websocket-advanced/websocket-cluster-messaging/src/test/java/io/backend/skeleton/websocket/cluster/messaging/WebSocketDurableFanoutEnvelopeTest.java' 'modules/websocket-advanced/websocket-cluster-messaging/src/main/java/io/backend/skeleton/websocket/cluster/messaging/WebSocketMessagingFanoutAdapter.java' + git commit -m "feat: messaging-durable-cross-node-fan-out" + ``` + +### Task 8: Presence Summary + + **Files:** + - Create: `modules/websocket-advanced/websocket-presence-redis/src/main/java/io/backend/skeleton/websocket/presence/WebSocketPresenceSummary.java` +- Create: `modules/websocket-advanced/websocket-presence-redis/src/main/java/io/backend/skeleton/websocket/presence/WebSocketPresenceStore.java` +- Test: `modules/websocket-advanced/websocket-presence-redis/src/test/java/io/backend/skeleton/websocket/presence/WebSocketPresenceSummaryTest.java` + + **Interfaces:** + - Consumes: Local/external session lifecycle와 Redis TTL. + - Produces: ONLINE·IDLE·STALE·OFFLINE 관측 모델. + + **Implementation requirements:** + - Presence를 절대 업무 사실로 사용하지 않는다. +- active connection count와 observed time을 함께 보존한다. +- 보안·결제 결정을 presence에 의존하지 않는다. + + - [ ] **Step 1: Write the failing test** + + ```java + package io.backend.skeleton.websocket.presence; + +class WebSocketPresenceSummaryTest { + @org.junit.jupiter.api.Test + void recordShapeIsStable() { + var names = java.util.Arrays.stream(WebSocketPresenceSummary.class.getRecordComponents()) + .map(java.lang.reflect.RecordComponent::getName) + .toList(); + org.assertj.core.api.Assertions.assertThat(names) + .containsExactly("actorFingerprint", "activeConnectionCount", "state", "lastObservedAt"); + } +} + ``` + + - [ ] **Step 2: Run the focused test and verify the failure** + + Run: + + ```bash + ./gradlew :modules:websocket-advanced:websocket-presence-redis:test --tests 'io.backend.skeleton.websocket.presence.WebSocketPresenceSummaryTest' + ``` + + Expected: FAIL because `WebSocketPresenceSummary` and its required policy contract do not exist yet. + + - [ ] **Step 3: Implement the smallest complete production contract** + + ```java + package io.backend.skeleton.websocket.presence; + +public record WebSocketPresenceSummary( + String actorFingerprint, + int activeConnectionCount, + String state, + java.time.Instant lastObservedAt) { + public WebSocketPresenceSummary { + java.util.Objects.requireNonNull(actorFingerprint, "actorFingerprint"); + java.util.Objects.requireNonNull(state, "state"); + java.util.Objects.requireNonNull(lastObservedAt, "lastObservedAt"); + } +} + ``` + + Implement every listed production file with the exact public names, boundaries and invariants above. Keep raw transport sessions, credentials, payloads, database types and dynamic identifiers out of the public contract unless this task explicitly defines a bounded adapter type. + + - [ ] **Step 4: Run the focused test and the owning suite** + + Run: + + ```bash + ./gradlew :modules:websocket-advanced:websocket-presence-redis:test --tests 'io.backend.skeleton.websocket.presence.WebSocketPresenceSummaryTest' + ./gradlew websocketAdvancedTest + ``` + + Expected: PASS for the focused test and the aggregate Advanced suite. + + - [ ] **Step 5: Commit the independently reviewable change** + + ```bash + git add 'modules/websocket-advanced/websocket-presence-redis/src/main/java/io/backend/skeleton/websocket/presence/WebSocketPresenceSummary.java' 'modules/websocket-advanced/websocket-presence-redis/src/test/java/io/backend/skeleton/websocket/presence/WebSocketPresenceSummaryTest.java' 'modules/websocket-advanced/websocket-presence-redis/src/main/java/io/backend/skeleton/websocket/presence/WebSocketPresenceStore.java' + git commit -m "feat: presence-summary" + ``` + +### Task 9: STOMP 1.2 Protocol Adapter + + **Files:** + - Create: `modules/websocket-advanced/websocket-stomp/src/main/java/io/backend/skeleton/websocket/stomp/WebSocketStompProfile.java` +- Create: `modules/websocket-advanced/websocket-stomp/src/main/java/io/backend/skeleton/websocket/stomp/WebSocketStompConfiguration.java` +- Create: `modules/websocket-advanced/websocket-stomp/src/main/java/io/backend/skeleton/websocket/stomp/WebSocketStompDestinationCatalog.java` +- Test: `modules/websocket-advanced/websocket-stomp/src/test/java/io/backend/skeleton/websocket/stomp/WebSocketStompProfileTest.java` + + **Interfaces:** + - Consumes: Stable connection runtime와 Spring Messaging. + - Produces: STOMP destination catalog와 protocol adapter. + + **Implementation requirements:** + - Destination 문자열을 reliability 보장으로 해석하지 않는다. +- application/broker/user prefix를 분리한다. +- 임의 SimpMessagingTemplate 사용을 application에서 금지한다. + + - [ ] **Step 1: Write the failing test** + + ```java + package io.backend.skeleton.websocket.stomp; + +class WebSocketStompProfileTest { + @org.junit.jupiter.api.Test + void recordShapeIsStable() { + var names = java.util.Arrays.stream(WebSocketStompProfile.class.getRecordComponents()) + .map(java.lang.reflect.RecordComponent::getName) + .toList(); + org.assertj.core.api.Assertions.assertThat(names) + .containsExactly("applicationPrefixes", "brokerPrefixes", "userPrefixes", "preserveOrder"); + } +} + ``` + + - [ ] **Step 2: Run the focused test and verify the failure** + + Run: + + ```bash + ./gradlew :modules:websocket-advanced:websocket-stomp:test --tests 'io.backend.skeleton.websocket.stomp.WebSocketStompProfileTest' + ``` + + Expected: FAIL because `WebSocketStompProfile` and its required policy contract do not exist yet. + + - [ ] **Step 3: Implement the smallest complete production contract** + + ```java + package io.backend.skeleton.websocket.stomp; + +public record WebSocketStompProfile( + java.util.Set applicationPrefixes, + java.util.Set brokerPrefixes, + java.util.Set userPrefixes, + boolean preserveOrder) { + public WebSocketStompProfile { + java.util.Objects.requireNonNull(applicationPrefixes, "applicationPrefixes"); + java.util.Objects.requireNonNull(brokerPrefixes, "brokerPrefixes"); + java.util.Objects.requireNonNull(userPrefixes, "userPrefixes"); + } +} + ``` + + Implement every listed production file with the exact public names, boundaries and invariants above. Keep raw transport sessions, credentials, payloads, database types and dynamic identifiers out of the public contract unless this task explicitly defines a bounded adapter type. + + - [ ] **Step 4: Run the focused test and the owning suite** + + Run: + + ```bash + ./gradlew :modules:websocket-advanced:websocket-stomp:test --tests 'io.backend.skeleton.websocket.stomp.WebSocketStompProfileTest' + ./gradlew websocketAdvancedTest + ``` + + Expected: PASS for the focused test and the aggregate Advanced suite. + + - [ ] **Step 5: Commit the independently reviewable change** + + ```bash + git add 'modules/websocket-advanced/websocket-stomp/src/main/java/io/backend/skeleton/websocket/stomp/WebSocketStompProfile.java' 'modules/websocket-advanced/websocket-stomp/src/test/java/io/backend/skeleton/websocket/stomp/WebSocketStompProfileTest.java' 'modules/websocket-advanced/websocket-stomp/src/main/java/io/backend/skeleton/websocket/stomp/WebSocketStompConfiguration.java' 'modules/websocket-advanced/websocket-stomp/src/main/java/io/backend/skeleton/websocket/stomp/WebSocketStompDestinationCatalog.java' + git commit -m "feat: stomp-1-2-protocol-adapter" + ``` + +### Task 10: STOMP Security·Receipt·ACK Evidence + + **Files:** + - Create: `modules/websocket-advanced/websocket-stomp/src/main/java/io/backend/skeleton/websocket/stomp/WebSocketStompEvidence.java` +- Create: `modules/websocket-advanced/websocket-stomp/src/main/java/io/backend/skeleton/websocket/stomp/WebSocketStompSecurityInterceptor.java` +- Create: `modules/websocket-advanced/websocket-stomp/src/main/java/io/backend/skeleton/websocket/stomp/WebSocketStompAckPolicy.java` +- Test: `modules/websocket-advanced/websocket-stomp/src/test/java/io/backend/skeleton/websocket/stomp/WebSocketStompEvidenceTest.java` + + **Interfaces:** + - Consumes: STOMP adapter와 Stable evidence model. + - Produces: RECEIPT·ACK·commit을 분리한 evidence mapping. + + **Implementation requirements:** + - RECEIPT을 transaction commit으로 승격하지 않는다. +- ack:auto·client·client-individual을 구분한다. +- MESSAGE와 SUBSCRIBE destination authorization을 분리한다. + + - [ ] **Step 1: Write the failing test** + + ```java + package io.backend.skeleton.websocket.stomp; + +class WebSocketStompEvidenceTest { + @org.junit.jupiter.api.Test + void valuesAreStable() { + org.assertj.core.api.Assertions.assertThat(WebSocketStompEvidence.values()) + .extracting(java.lang.Enum::name) + .containsExactly("FRAME_RECEIVED", "PROTOCOL_RECEIPT", "BROKER_DELIVERY", "BROKER_ACK", "APPLICATION_COMMIT", "CLIENT_APPLIED"); + } +} + ``` + + - [ ] **Step 2: Run the focused test and verify the failure** + + Run: + + ```bash + ./gradlew :modules:websocket-advanced:websocket-stomp:test --tests 'io.backend.skeleton.websocket.stomp.WebSocketStompEvidenceTest' + ``` + + Expected: FAIL because `WebSocketStompEvidence` and its required policy contract do not exist yet. + + - [ ] **Step 3: Implement the smallest complete production contract** + + ```java + package io.backend.skeleton.websocket.stomp; + +public enum WebSocketStompEvidence { + FRAME_RECEIVED, + PROTOCOL_RECEIPT, + BROKER_DELIVERY, + BROKER_ACK, + APPLICATION_COMMIT, + CLIENT_APPLIED +} + ``` + + Implement every listed production file with the exact public names, boundaries and invariants above. Keep raw transport sessions, credentials, payloads, database types and dynamic identifiers out of the public contract unless this task explicitly defines a bounded adapter type. + + - [ ] **Step 4: Run the focused test and the owning suite** + + Run: + + ```bash + ./gradlew :modules:websocket-advanced:websocket-stomp:test --tests 'io.backend.skeleton.websocket.stomp.WebSocketStompEvidenceTest' + ./gradlew websocketAdvancedTest + ``` + + Expected: PASS for the focused test and the aggregate Advanced suite. + + - [ ] **Step 5: Commit the independently reviewable change** + + ```bash + git add 'modules/websocket-advanced/websocket-stomp/src/main/java/io/backend/skeleton/websocket/stomp/WebSocketStompEvidence.java' 'modules/websocket-advanced/websocket-stomp/src/test/java/io/backend/skeleton/websocket/stomp/WebSocketStompEvidenceTest.java' 'modules/websocket-advanced/websocket-stomp/src/main/java/io/backend/skeleton/websocket/stomp/WebSocketStompSecurityInterceptor.java' 'modules/websocket-advanced/websocket-stomp/src/main/java/io/backend/skeleton/websocket/stomp/WebSocketStompAckPolicy.java' + git commit -m "feat: stomp-security-receipt-ack-evidence" + ``` + +### Task 11: Simple Broker Local/Test Profile + + **Files:** + - Create: `modules/websocket-advanced/websocket-stomp/src/main/java/io/backend/skeleton/websocket/stomp/WebSocketSimpleBrokerProfile.java` +- Create: `modules/websocket-advanced/websocket-stomp/src/test/java/io/backend/skeleton/websocket/stomp/WebSocketSimpleBrokerContractTest.java` +- Test: `modules/websocket-advanced/websocket-stomp/src/test/java/io/backend/skeleton/websocket/stomp/WebSocketSimpleBrokerProfileTest.java` + + **Interfaces:** + - Consumes: STOMP configuration. + - Produces: Simple Broker의 단일 node·제한 기능 명시. + + **Implementation requirements:** + - clusterSupported와 durableAckSupported는 false다. +- Local/Test profile 밖에서 activation을 거부한다. +- ACK·Receipt limitation을 support matrix에 기록한다. + + - [ ] **Step 1: Write the failing test** + + ```java + package io.backend.skeleton.websocket.stomp; + +class WebSocketSimpleBrokerProfileTest { + @org.junit.jupiter.api.Test + void recordShapeIsStable() { + var names = java.util.Arrays.stream(WebSocketSimpleBrokerProfile.class.getRecordComponents()) + .map(java.lang.reflect.RecordComponent::getName) + .toList(); + org.assertj.core.api.Assertions.assertThat(names) + .containsExactly("localOnly", "clusterSupported", "durableAckSupported"); + } +} + ``` + + - [ ] **Step 2: Run the focused test and verify the failure** + + Run: + + ```bash + ./gradlew :modules:websocket-advanced:websocket-stomp:test --tests 'io.backend.skeleton.websocket.stomp.WebSocketSimpleBrokerProfileTest' + ``` + + Expected: FAIL because `WebSocketSimpleBrokerProfile` and its required policy contract do not exist yet. + + - [ ] **Step 3: Implement the smallest complete production contract** + + ```java + package io.backend.skeleton.websocket.stomp; + +public record WebSocketSimpleBrokerProfile( + boolean localOnly, + boolean clusterSupported, + boolean durableAckSupported) { + public WebSocketSimpleBrokerProfile { + + } +} + ``` + + Implement every listed production file with the exact public names, boundaries and invariants above. Keep raw transport sessions, credentials, payloads, database types and dynamic identifiers out of the public contract unless this task explicitly defines a bounded adapter type. + + - [ ] **Step 4: Run the focused test and the owning suite** + + Run: + + ```bash + ./gradlew :modules:websocket-advanced:websocket-stomp:test --tests 'io.backend.skeleton.websocket.stomp.WebSocketSimpleBrokerProfileTest' + ./gradlew websocketAdvancedTest + ``` + + Expected: PASS for the focused test and the aggregate Advanced suite. + + - [ ] **Step 5: Commit the independently reviewable change** + + ```bash + git add 'modules/websocket-advanced/websocket-stomp/src/main/java/io/backend/skeleton/websocket/stomp/WebSocketSimpleBrokerProfile.java' 'modules/websocket-advanced/websocket-stomp/src/test/java/io/backend/skeleton/websocket/stomp/WebSocketSimpleBrokerProfileTest.java' 'modules/websocket-advanced/websocket-stomp/src/test/java/io/backend/skeleton/websocket/stomp/WebSocketSimpleBrokerContractTest.java' + git commit -m "feat: simple-broker-local-test-profile" + ``` + +### Task 12: RabbitMQ STOMP Broker Relay + + **Files:** + - Create: `modules/websocket-advanced/websocket-broker-relay-rabbit/src/main/java/io/backend/skeleton/websocket/stomp/rabbit/WebSocketRabbitBrokerRelayProfile.java` +- Create: `modules/websocket-advanced/websocket-broker-relay-rabbit/src/main/java/io/backend/skeleton/websocket/stomp/rabbit/WebSocketRabbitBrokerRelayConfiguration.java` +- Create: `modules/websocket-advanced/websocket-broker-relay-rabbit/src/test/java/io/backend/skeleton/websocket/stomp/rabbit/WebSocketRabbitBrokerRelayContractTest.java` +- Test: `modules/websocket-advanced/websocket-broker-relay-rabbit/src/test/java/io/backend/skeleton/websocket/stomp/rabbit/WebSocketRabbitBrokerRelayProfileTest.java` + + **Interfaces:** + - Consumes: STOMP adapter와 RabbitMQ broker capability. + - Produces: external relay connection·heartbeat·outage contract. + + **Implementation requirements:** + - client connection별 broker connection 비용을 관측한다. +- broker outage와 reconnect에서 session behavior를 검증한다. +- destination durability는 Rabbit topology capability에서만 선언한다. + + - [ ] **Step 1: Write the failing test** + + ```java + package io.backend.skeleton.websocket.stomp.rabbit; + +class WebSocketRabbitBrokerRelayProfileTest { + @org.junit.jupiter.api.Test + void recordShapeIsStable() { + var names = java.util.Arrays.stream(WebSocketRabbitBrokerRelayProfile.class.getRecordComponents()) + .map(java.lang.reflect.RecordComponent::getName) + .toList(); + org.assertj.core.api.Assertions.assertThat(names) + .containsExactly("host", "port", "tls", "systemHeartbeat"); + } +} + ``` + + - [ ] **Step 2: Run the focused test and verify the failure** + + Run: + + ```bash + ./gradlew :modules:websocket-advanced:websocket-broker-relay-rabbit:test --tests 'io.backend.skeleton.websocket.stomp.rabbit.WebSocketRabbitBrokerRelayProfileTest' + ``` + + Expected: FAIL because `WebSocketRabbitBrokerRelayProfile` and its required policy contract do not exist yet. + + - [ ] **Step 3: Implement the smallest complete production contract** + + ```java + package io.backend.skeleton.websocket.stomp.rabbit; + +public record WebSocketRabbitBrokerRelayProfile( + String host, + int port, + boolean tls, + java.time.Duration systemHeartbeat) { + public WebSocketRabbitBrokerRelayProfile { + java.util.Objects.requireNonNull(host, "host"); + java.util.Objects.requireNonNull(systemHeartbeat, "systemHeartbeat"); + } +} + ``` + + Implement every listed production file with the exact public names, boundaries and invariants above. Keep raw transport sessions, credentials, payloads, database types and dynamic identifiers out of the public contract unless this task explicitly defines a bounded adapter type. + + - [ ] **Step 4: Run the focused test and the owning suite** + + Run: + + ```bash + ./gradlew :modules:websocket-advanced:websocket-broker-relay-rabbit:test --tests 'io.backend.skeleton.websocket.stomp.rabbit.WebSocketRabbitBrokerRelayProfileTest' + ./gradlew websocketAdvancedTest + ``` + + Expected: PASS for the focused test and the aggregate Advanced suite. + + - [ ] **Step 5: Commit the independently reviewable change** + + ```bash + git add 'modules/websocket-advanced/websocket-broker-relay-rabbit/src/main/java/io/backend/skeleton/websocket/stomp/rabbit/WebSocketRabbitBrokerRelayProfile.java' 'modules/websocket-advanced/websocket-broker-relay-rabbit/src/test/java/io/backend/skeleton/websocket/stomp/rabbit/WebSocketRabbitBrokerRelayProfileTest.java' 'modules/websocket-advanced/websocket-broker-relay-rabbit/src/main/java/io/backend/skeleton/websocket/stomp/rabbit/WebSocketRabbitBrokerRelayConfiguration.java' 'modules/websocket-advanced/websocket-broker-relay-rabbit/src/test/java/io/backend/skeleton/websocket/stomp/rabbit/WebSocketRabbitBrokerRelayContractTest.java' + git commit -m "feat: rabbitmq-stomp-broker-relay" + ``` + +### Task 13: Multi-node User Destination + + **Files:** + - Create: `modules/websocket-advanced/websocket-broker-relay-rabbit/src/main/java/io/backend/skeleton/websocket/stomp/rabbit/WebSocketUserDestinationPolicy.java` +- Create: `modules/websocket-advanced/websocket-broker-relay-rabbit/src/main/java/io/backend/skeleton/websocket/stomp/rabbit/WebSocketMultiNodeUserDestination.java` +- Test: `modules/websocket-advanced/websocket-broker-relay-rabbit/src/test/java/io/backend/skeleton/websocket/stomp/rabbit/WebSocketUserDestinationPolicyTest.java` + + **Interfaces:** + - Consumes: External session index와 Rabbit relay. + - Produces: 다른 node에 연결된 user session resolution. + + **Implementation requirements:** + - user destination 원문을 metric tag로 사용하지 않는다. +- unresolved broadcast loop를 방지한다. +- broker temporary queue cleanup을 검증한다. + + - [ ] **Step 1: Write the failing test** + + ```java + package io.backend.skeleton.websocket.stomp.rabbit; + +class WebSocketUserDestinationPolicyTest { + @org.junit.jupiter.api.Test + void recordShapeIsStable() { + var names = java.util.Arrays.stream(WebSocketUserDestinationPolicy.class.getRecordComponents()) + .map(java.lang.reflect.RecordComponent::getName) + .toList(); + org.assertj.core.api.Assertions.assertThat(names) + .containsExactly("broadcastDestination", "unresolvedDestination", "registryTtl"); + } +} + ``` + + - [ ] **Step 2: Run the focused test and verify the failure** + + Run: + + ```bash + ./gradlew :modules:websocket-advanced:websocket-broker-relay-rabbit:test --tests 'io.backend.skeleton.websocket.stomp.rabbit.WebSocketUserDestinationPolicyTest' + ``` + + Expected: FAIL because `WebSocketUserDestinationPolicy` and its required policy contract do not exist yet. + + - [ ] **Step 3: Implement the smallest complete production contract** + + ```java + package io.backend.skeleton.websocket.stomp.rabbit; + +public record WebSocketUserDestinationPolicy( + String broadcastDestination, + String unresolvedDestination, + java.time.Duration registryTtl) { + public WebSocketUserDestinationPolicy { + java.util.Objects.requireNonNull(broadcastDestination, "broadcastDestination"); + java.util.Objects.requireNonNull(unresolvedDestination, "unresolvedDestination"); + java.util.Objects.requireNonNull(registryTtl, "registryTtl"); + } +} + ``` + + Implement every listed production file with the exact public names, boundaries and invariants above. Keep raw transport sessions, credentials, payloads, database types and dynamic identifiers out of the public contract unless this task explicitly defines a bounded adapter type. + + - [ ] **Step 4: Run the focused test and the owning suite** + + Run: + + ```bash + ./gradlew :modules:websocket-advanced:websocket-broker-relay-rabbit:test --tests 'io.backend.skeleton.websocket.stomp.rabbit.WebSocketUserDestinationPolicyTest' + ./gradlew websocketAdvancedTest + ``` + + Expected: PASS for the focused test and the aggregate Advanced suite. + + - [ ] **Step 5: Commit the independently reviewable change** + + ```bash + git add 'modules/websocket-advanced/websocket-broker-relay-rabbit/src/main/java/io/backend/skeleton/websocket/stomp/rabbit/WebSocketUserDestinationPolicy.java' 'modules/websocket-advanced/websocket-broker-relay-rabbit/src/test/java/io/backend/skeleton/websocket/stomp/rabbit/WebSocketUserDestinationPolicyTest.java' 'modules/websocket-advanced/websocket-broker-relay-rabbit/src/main/java/io/backend/skeleton/websocket/stomp/rabbit/WebSocketMultiNodeUserDestination.java' + git commit -m "feat: multi-node-user-destination" + ``` + +### Task 14: Protobuf Binary Codec + + **Files:** + - Create: `modules/websocket-advanced/websocket-protobuf/src/main/java/io/backend/skeleton/websocket/codec/protobuf/WebSocketProtobufCodecProfile.java` +- Create: `modules/websocket-advanced/websocket-protobuf/src/main/java/io/backend/skeleton/websocket/codec/protobuf/WebSocketProtobufCodec.java` +- Test: `modules/websocket-advanced/websocket-protobuf/src/test/java/io/backend/skeleton/websocket/codec/protobuf/WebSocketProtobufCodecProfileTest.java` + + **Interfaces:** + - Consumes: Stable message catalog와 generated Protobuf descriptors. + - Produces: versioned binary protocol codec. + + **Implementation requirements:** + - Stable JSON과 같은 message type/version 의미를 유지한다. +- descriptor compatibility gate를 요구한다. +- 대형 file bytes를 protobuf payload로 허용하지 않는다. + + - [ ] **Step 1: Write the failing test** + + ```java + package io.backend.skeleton.websocket.codec.protobuf; + +class WebSocketProtobufCodecProfileTest { + @org.junit.jupiter.api.Test + void recordShapeIsStable() { + var names = java.util.Arrays.stream(WebSocketProtobufCodecProfile.class.getRecordComponents()) + .map(java.lang.reflect.RecordComponent::getName) + .toList(); + org.assertj.core.api.Assertions.assertThat(names) + .containsExactly("descriptorArtifact", "maxMessageBytes", "unknownFieldAllowed"); + } +} + ``` + + - [ ] **Step 2: Run the focused test and verify the failure** + + Run: + + ```bash + ./gradlew :modules:websocket-advanced:websocket-protobuf:test --tests 'io.backend.skeleton.websocket.codec.protobuf.WebSocketProtobufCodecProfileTest' + ``` + + Expected: FAIL because `WebSocketProtobufCodecProfile` and its required policy contract do not exist yet. + + - [ ] **Step 3: Implement the smallest complete production contract** + + ```java + package io.backend.skeleton.websocket.codec.protobuf; + +public record WebSocketProtobufCodecProfile( + String descriptorArtifact, + int maxMessageBytes, + boolean unknownFieldAllowed) { + public WebSocketProtobufCodecProfile { + java.util.Objects.requireNonNull(descriptorArtifact, "descriptorArtifact"); + } +} + ``` + + Implement every listed production file with the exact public names, boundaries and invariants above. Keep raw transport sessions, credentials, payloads, database types and dynamic identifiers out of the public contract unless this task explicitly defines a bounded adapter type. + + - [ ] **Step 4: Run the focused test and the owning suite** + + Run: + + ```bash + ./gradlew :modules:websocket-advanced:websocket-protobuf:test --tests 'io.backend.skeleton.websocket.codec.protobuf.WebSocketProtobufCodecProfileTest' + ./gradlew websocketAdvancedTest + ``` + + Expected: PASS for the focused test and the aggregate Advanced suite. + + - [ ] **Step 5: Commit the independently reviewable change** + + ```bash + git add 'modules/websocket-advanced/websocket-protobuf/src/main/java/io/backend/skeleton/websocket/codec/protobuf/WebSocketProtobufCodecProfile.java' 'modules/websocket-advanced/websocket-protobuf/src/test/java/io/backend/skeleton/websocket/codec/protobuf/WebSocketProtobufCodecProfileTest.java' 'modules/websocket-advanced/websocket-protobuf/src/main/java/io/backend/skeleton/websocket/codec/protobuf/WebSocketProtobufCodec.java' + git commit -m "feat: protobuf-binary-codec" + ``` + +### Task 15: CBOR Binary Codec + + **Files:** + - Create: `modules/websocket-advanced/websocket-cbor/src/main/java/io/backend/skeleton/websocket/codec/cbor/WebSocketCborCodecProfile.java` +- Create: `modules/websocket-advanced/websocket-cbor/src/main/java/io/backend/skeleton/websocket/codec/cbor/WebSocketCborCodec.java` +- Test: `modules/websocket-advanced/websocket-cbor/src/test/java/io/backend/skeleton/websocket/codec/cbor/WebSocketCborCodecProfileTest.java` + + **Interfaces:** + - Consumes: Stable message catalog와 wire type manifest. + - Produces: bounded CBOR codec profile. + + **Implementation requirements:** + - 실제 client demand가 있을 때만 활성화한다. +- canonical encoding과 duplicate map key 정책을 고정한다. +- JSON과 semantic schema parity test를 수행한다. + + - [ ] **Step 1: Write the failing test** + + ```java + package io.backend.skeleton.websocket.codec.cbor; + +class WebSocketCborCodecProfileTest { + @org.junit.jupiter.api.Test + void recordShapeIsStable() { + var names = java.util.Arrays.stream(WebSocketCborCodecProfile.class.getRecordComponents()) + .map(java.lang.reflect.RecordComponent::getName) + .toList(); + org.assertj.core.api.Assertions.assertThat(names) + .containsExactly("maxMessageBytes", "canonicalEncodingRequired", "unknownFieldAllowed"); + } +} + ``` + + - [ ] **Step 2: Run the focused test and verify the failure** + + Run: + + ```bash + ./gradlew :modules:websocket-advanced:websocket-cbor:test --tests 'io.backend.skeleton.websocket.codec.cbor.WebSocketCborCodecProfileTest' + ``` + + Expected: FAIL because `WebSocketCborCodecProfile` and its required policy contract do not exist yet. + + - [ ] **Step 3: Implement the smallest complete production contract** + + ```java + package io.backend.skeleton.websocket.codec.cbor; + +public record WebSocketCborCodecProfile( + int maxMessageBytes, + boolean canonicalEncodingRequired, + boolean unknownFieldAllowed) { + public WebSocketCborCodecProfile { + + } +} + ``` + + Implement every listed production file with the exact public names, boundaries and invariants above. Keep raw transport sessions, credentials, payloads, database types and dynamic identifiers out of the public contract unless this task explicitly defines a bounded adapter type. + + - [ ] **Step 4: Run the focused test and the owning suite** + + Run: + + ```bash + ./gradlew :modules:websocket-advanced:websocket-cbor:test --tests 'io.backend.skeleton.websocket.codec.cbor.WebSocketCborCodecProfileTest' + ./gradlew websocketAdvancedTest + ``` + + Expected: PASS for the focused test and the aggregate Advanced suite. + + - [ ] **Step 5: Commit the independently reviewable change** + + ```bash + git add 'modules/websocket-advanced/websocket-cbor/src/main/java/io/backend/skeleton/websocket/codec/cbor/WebSocketCborCodecProfile.java' 'modules/websocket-advanced/websocket-cbor/src/test/java/io/backend/skeleton/websocket/codec/cbor/WebSocketCborCodecProfileTest.java' 'modules/websocket-advanced/websocket-cbor/src/main/java/io/backend/skeleton/websocket/codec/cbor/WebSocketCborCodec.java' + git commit -m "feat: cbor-binary-codec" + ``` + +### Task 16: permessage-deflate Endpoint Opt-in + + **Files:** + - Create: `modules/websocket-advanced/websocket-compression/src/main/java/io/backend/skeleton/websocket/compression/WebSocketCompressionProfile.java` +- Create: `modules/websocket-advanced/websocket-compression/src/main/java/io/backend/skeleton/websocket/compression/WebSocketCompressionPolicy.java` +- Test: `modules/websocket-advanced/websocket-compression/src/test/java/io/backend/skeleton/websocket/compression/WebSocketCompressionProfileTest.java` + + **Interfaces:** + - Consumes: Endpoint profile와 runtime extension negotiation. + - Produces: RFC 7692 memory·CPU bounded compression policy. + + **Implementation requirements:** + - 기본 enabled=false다. +- decompressed size limit를 별도로 적용한다. +- sensitive data와 attacker-controlled input 혼합 endpoint를 거부한다. + + - [ ] **Step 1: Write the failing test** + + ```java + package io.backend.skeleton.websocket.compression; + +class WebSocketCompressionProfileTest { + @org.junit.jupiter.api.Test + void recordShapeIsStable() { + var names = java.util.Arrays.stream(WebSocketCompressionProfile.class.getRecordComponents()) + .map(java.lang.reflect.RecordComponent::getName) + .toList(); + org.assertj.core.api.Assertions.assertThat(names) + .containsExactly("enabled", "serverNoContextTakeover", "clientNoContextTakeover", "maxWindowBits", "minCompressBytes"); + } +} + ``` + + - [ ] **Step 2: Run the focused test and verify the failure** + + Run: + + ```bash + ./gradlew :modules:websocket-advanced:websocket-compression:test --tests 'io.backend.skeleton.websocket.compression.WebSocketCompressionProfileTest' + ``` + + Expected: FAIL because `WebSocketCompressionProfile` and its required policy contract do not exist yet. + + - [ ] **Step 3: Implement the smallest complete production contract** + + ```java + package io.backend.skeleton.websocket.compression; + +public record WebSocketCompressionProfile( + boolean enabled, + boolean serverNoContextTakeover, + boolean clientNoContextTakeover, + int maxWindowBits, + int minCompressBytes) { + public WebSocketCompressionProfile { + + } +} + ``` + + Implement every listed production file with the exact public names, boundaries and invariants above. Keep raw transport sessions, credentials, payloads, database types and dynamic identifiers out of the public contract unless this task explicitly defines a bounded adapter type. + + - [ ] **Step 4: Run the focused test and the owning suite** + + Run: + + ```bash + ./gradlew :modules:websocket-advanced:websocket-compression:test --tests 'io.backend.skeleton.websocket.compression.WebSocketCompressionProfileTest' + ./gradlew websocketAdvancedTest + ``` + + Expected: PASS for the focused test and the aggregate Advanced suite. + + - [ ] **Step 5: Commit the independently reviewable change** + + ```bash + git add 'modules/websocket-advanced/websocket-compression/src/main/java/io/backend/skeleton/websocket/compression/WebSocketCompressionProfile.java' 'modules/websocket-advanced/websocket-compression/src/test/java/io/backend/skeleton/websocket/compression/WebSocketCompressionProfileTest.java' 'modules/websocket-advanced/websocket-compression/src/main/java/io/backend/skeleton/websocket/compression/WebSocketCompressionPolicy.java' + git commit -m "feat: permessage-deflate-endpoint-opt-in" + ``` + +### Task 17: Outbound WebSocket Client Profile + + **Files:** + - Create: `modules/websocket-advanced/websocket-outbound-client/src/main/java/io/backend/skeleton/websocket/client/NamedWebSocketClientProfile.java` +- Create: `modules/websocket-advanced/websocket-outbound-client/src/main/java/io/backend/skeleton/websocket/client/NamedWebSocketClientRegistry.java` +- Create: `modules/websocket-advanced/websocket-outbound-client/src/main/java/io/backend/skeleton/websocket/client/WebSocketClientReconnectPolicy.java` +- Test: `modules/websocket-advanced/websocket-outbound-client/src/test/java/io/backend/skeleton/websocket/client/NamedWebSocketClientProfileTest.java` + + **Interfaces:** + - Consumes: Stable protocol·security·heartbeat contracts. + - Produces: named outbound connection·TLS·reconnect·resume profile. + + **Implementation requirements:** + - 일반 HTTP Client retry를 그대로 사용하지 않는다. +- endpoint·TLS·subprotocol을 profile에 고정한다. +- reconnect는 backoff+jitter와 bounded attempts를 사용한다. + + - [ ] **Step 1: Write the failing test** + + ```java + package io.backend.skeleton.websocket.client; + +class NamedWebSocketClientProfileTest { + @org.junit.jupiter.api.Test + void recordShapeIsStable() { + var names = java.util.Arrays.stream(NamedWebSocketClientProfile.class.getRecordComponents()) + .map(java.lang.reflect.RecordComponent::getName) + .toList(); + org.assertj.core.api.Assertions.assertThat(names) + .containsExactly("name", "uri", "subprotocol", "connectTimeout", "idleTimeout"); + } +} + ``` + + - [ ] **Step 2: Run the focused test and verify the failure** + + Run: + + ```bash + ./gradlew :modules:websocket-advanced:websocket-outbound-client:test --tests 'io.backend.skeleton.websocket.client.NamedWebSocketClientProfileTest' + ``` + + Expected: FAIL because `NamedWebSocketClientProfile` and its required policy contract do not exist yet. + + - [ ] **Step 3: Implement the smallest complete production contract** + + ```java + package io.backend.skeleton.websocket.client; + +public record NamedWebSocketClientProfile( + String name, + String uri, + String subprotocol, + java.time.Duration connectTimeout, + java.time.Duration idleTimeout) { + public NamedWebSocketClientProfile { + java.util.Objects.requireNonNull(name, "name"); + java.util.Objects.requireNonNull(uri, "uri"); + java.util.Objects.requireNonNull(subprotocol, "subprotocol"); + java.util.Objects.requireNonNull(connectTimeout, "connectTimeout"); + java.util.Objects.requireNonNull(idleTimeout, "idleTimeout"); + } +} + ``` + + Implement every listed production file with the exact public names, boundaries and invariants above. Keep raw transport sessions, credentials, payloads, database types and dynamic identifiers out of the public contract unless this task explicitly defines a bounded adapter type. + + - [ ] **Step 4: Run the focused test and the owning suite** + + Run: + + ```bash + ./gradlew :modules:websocket-advanced:websocket-outbound-client:test --tests 'io.backend.skeleton.websocket.client.NamedWebSocketClientProfileTest' + ./gradlew websocketAdvancedTest + ``` + + Expected: PASS for the focused test and the aggregate Advanced suite. + + - [ ] **Step 5: Commit the independently reviewable change** + + ```bash + git add 'modules/websocket-advanced/websocket-outbound-client/src/main/java/io/backend/skeleton/websocket/client/NamedWebSocketClientProfile.java' 'modules/websocket-advanced/websocket-outbound-client/src/test/java/io/backend/skeleton/websocket/client/NamedWebSocketClientProfileTest.java' 'modules/websocket-advanced/websocket-outbound-client/src/main/java/io/backend/skeleton/websocket/client/NamedWebSocketClientRegistry.java' 'modules/websocket-advanced/websocket-outbound-client/src/main/java/io/backend/skeleton/websocket/client/WebSocketClientReconnectPolicy.java' + git commit -m "feat: outbound-websocket-client-profile" + ``` + +### Task 18: SockJS Legacy Compatibility + + **Files:** + - Create: `modules/websocket-advanced/websocket-sockjs-compat/src/main/java/io/backend/skeleton/websocket/sockjs/WebSocketSockJsCompatibilityProfile.java` +- Create: `modules/websocket-advanced/websocket-sockjs-compat/src/main/java/io/backend/skeleton/websocket/sockjs/WebSocketSockJsConfiguration.java` +- Test: `modules/websocket-advanced/websocket-sockjs-compat/src/test/java/io/backend/skeleton/websocket/sockjs/WebSocketSockJsCompatibilityProfileTest.java` + + **Interfaces:** + - Consumes: STOMP 또는 Raw compatibility endpoint. + - Produces: legacy browser transport fallback profile. + + **Implementation requirements:** + - 신규 서비스 기본에서 비활성이다. +- Origin·CSRF·session budget을 Stable보다 완화하지 않는다. +- fallback transport별 proxy/cache behavior를 시험한다. + + - [ ] **Step 1: Write the failing test** + + ```java + package io.backend.skeleton.websocket.sockjs; + +class WebSocketSockJsCompatibilityProfileTest { + @org.junit.jupiter.api.Test + void recordShapeIsStable() { + var names = java.util.Arrays.stream(WebSocketSockJsCompatibilityProfile.class.getRecordComponents()) + .map(java.lang.reflect.RecordComponent::getName) + .toList(); + org.assertj.core.api.Assertions.assertThat(names) + .containsExactly("enabled", "transports", "sessionCookieLifetime"); + } +} + ``` + + - [ ] **Step 2: Run the focused test and verify the failure** + + Run: + + ```bash + ./gradlew :modules:websocket-advanced:websocket-sockjs-compat:test --tests 'io.backend.skeleton.websocket.sockjs.WebSocketSockJsCompatibilityProfileTest' + ``` + + Expected: FAIL because `WebSocketSockJsCompatibilityProfile` and its required policy contract do not exist yet. + + - [ ] **Step 3: Implement the smallest complete production contract** + + ```java + package io.backend.skeleton.websocket.sockjs; + +public record WebSocketSockJsCompatibilityProfile( + boolean enabled, + java.util.Set transports, + java.time.Duration sessionCookieLifetime) { + public WebSocketSockJsCompatibilityProfile { + java.util.Objects.requireNonNull(transports, "transports"); + java.util.Objects.requireNonNull(sessionCookieLifetime, "sessionCookieLifetime"); + } +} + ``` + + Implement every listed production file with the exact public names, boundaries and invariants above. Keep raw transport sessions, credentials, payloads, database types and dynamic identifiers out of the public contract unless this task explicitly defines a bounded adapter type. + + - [ ] **Step 4: Run the focused test and the owning suite** + + Run: + + ```bash + ./gradlew :modules:websocket-advanced:websocket-sockjs-compat:test --tests 'io.backend.skeleton.websocket.sockjs.WebSocketSockJsCompatibilityProfileTest' + ./gradlew websocketAdvancedTest + ``` + + Expected: PASS for the focused test and the aggregate Advanced suite. + + - [ ] **Step 5: Commit the independently reviewable change** + + ```bash + git add 'modules/websocket-advanced/websocket-sockjs-compat/src/main/java/io/backend/skeleton/websocket/sockjs/WebSocketSockJsCompatibilityProfile.java' 'modules/websocket-advanced/websocket-sockjs-compat/src/test/java/io/backend/skeleton/websocket/sockjs/WebSocketSockJsCompatibilityProfileTest.java' 'modules/websocket-advanced/websocket-sockjs-compat/src/main/java/io/backend/skeleton/websocket/sockjs/WebSocketSockJsConfiguration.java' + git commit -m "feat: sockjs-legacy-compatibility" + ``` + +### Task 19: HTTP/2 Extended CONNECT Compatibility + + **Files:** + - Create: `modules/websocket-advanced/websocket-http2-compat/src/main/java/io/backend/skeleton/websocket/http2/WebSocketHttp2CompatibilityProfile.java` +- Create: `modules/websocket-advanced/websocket-http2-compat/src/test/java/io/backend/skeleton/websocket/http2/WebSocketHttp2EndToEndContractTest.java` +- Test: `modules/websocket-advanced/websocket-http2-compat/src/test/java/io/backend/skeleton/websocket/http2/WebSocketHttp2CompatibilityProfileTest.java` + + **Interfaces:** + - Consumes: Stable runtime과 RFC 8441 capable path. + - Produces: Client–Nginx/Ingress–Runtime E2E evidence. + + **Implementation requirements:** + - 표준 존재와 플랫폼 지원을 구분한다. +- 모든 hop이 Extended CONNECT를 지원할 때만 enable한다. +- fallback classic Upgrade behavior를 검증한다. + + - [ ] **Step 1: Write the failing test** + + ```java + package io.backend.skeleton.websocket.http2; + +class WebSocketHttp2CompatibilityProfileTest { + @org.junit.jupiter.api.Test + void recordShapeIsStable() { + var names = java.util.Arrays.stream(WebSocketHttp2CompatibilityProfile.class.getRecordComponents()) + .map(java.lang.reflect.RecordComponent::getName) + .toList(); + org.assertj.core.api.Assertions.assertThat(names) + .containsExactly("enabled", "validatedClients", "validatedProxies"); + } +} + ``` + + - [ ] **Step 2: Run the focused test and verify the failure** + + Run: + + ```bash + ./gradlew :modules:websocket-advanced:websocket-http2-compat:test --tests 'io.backend.skeleton.websocket.http2.WebSocketHttp2CompatibilityProfileTest' + ``` + + Expected: FAIL because `WebSocketHttp2CompatibilityProfile` and its required policy contract do not exist yet. + + - [ ] **Step 3: Implement the smallest complete production contract** + + ```java + package io.backend.skeleton.websocket.http2; + +public record WebSocketHttp2CompatibilityProfile( + boolean enabled, + java.util.Set validatedClients, + java.util.Set validatedProxies) { + public WebSocketHttp2CompatibilityProfile { + java.util.Objects.requireNonNull(validatedClients, "validatedClients"); + java.util.Objects.requireNonNull(validatedProxies, "validatedProxies"); + } +} + ``` + + Implement every listed production file with the exact public names, boundaries and invariants above. Keep raw transport sessions, credentials, payloads, database types and dynamic identifiers out of the public contract unless this task explicitly defines a bounded adapter type. + + - [ ] **Step 4: Run the focused test and the owning suite** + + Run: + + ```bash + ./gradlew :modules:websocket-advanced:websocket-http2-compat:test --tests 'io.backend.skeleton.websocket.http2.WebSocketHttp2CompatibilityProfileTest' + ./gradlew websocketAdvancedTest + ``` + + Expected: PASS for the focused test and the aggregate Advanced suite. + + - [ ] **Step 5: Commit the independently reviewable change** + + ```bash + git add 'modules/websocket-advanced/websocket-http2-compat/src/main/java/io/backend/skeleton/websocket/http2/WebSocketHttp2CompatibilityProfile.java' 'modules/websocket-advanced/websocket-http2-compat/src/test/java/io/backend/skeleton/websocket/http2/WebSocketHttp2CompatibilityProfileTest.java' 'modules/websocket-advanced/websocket-http2-compat/src/test/java/io/backend/skeleton/websocket/http2/WebSocketHttp2EndToEndContractTest.java' + git commit -m "feat: http-2-extended-connect-compatibility" + ``` + +### Task 20: HTTP/3 WebSocket Experimental + + **Files:** + - Create: `modules/websocket-advanced/websocket-http3-experimental/src/main/java/io/backend/skeleton/websocket/http3/WebSocketHttp3ExperimentalProfile.java` +- Create: `modules/websocket-advanced/websocket-http3-experimental/src/test/java/io/backend/skeleton/websocket/http3/WebSocketHttp3ExperimentalContractTest.java` +- Test: `modules/websocket-advanced/websocket-http3-experimental/src/test/java/io/backend/skeleton/websocket/http3/WebSocketHttp3ExperimentalProfileTest.java` + + **Interfaces:** + - Consumes: RFC 9220 capable client/proxy/runtime. + - Produces: Experimental E2E compatibility evidence only. + + **Implementation requirements:** + - Stable support로 광고하지 않는다. +- QUIC·proxy·browser matrix를 별도로 기록한다. +- fallback·rollback path가 없으면 production promotion을 금지한다. + + - [ ] **Step 1: Write the failing test** + + ```java + package io.backend.skeleton.websocket.http3; + +class WebSocketHttp3ExperimentalProfileTest { + @org.junit.jupiter.api.Test + void recordShapeIsStable() { + var names = java.util.Arrays.stream(WebSocketHttp3ExperimentalProfile.class.getRecordComponents()) + .map(java.lang.reflect.RecordComponent::getName) + .toList(); + org.assertj.core.api.Assertions.assertThat(names) + .containsExactly("enabled", "quicImplementation", "validatedClients"); + } +} + ``` + + - [ ] **Step 2: Run the focused test and verify the failure** + + Run: + + ```bash + ./gradlew :modules:websocket-advanced:websocket-http3-experimental:test --tests 'io.backend.skeleton.websocket.http3.WebSocketHttp3ExperimentalProfileTest' + ``` + + Expected: FAIL because `WebSocketHttp3ExperimentalProfile` and its required policy contract do not exist yet. + + - [ ] **Step 3: Implement the smallest complete production contract** + + ```java + package io.backend.skeleton.websocket.http3; + +public record WebSocketHttp3ExperimentalProfile( + boolean enabled, + String quicImplementation, + java.util.Set validatedClients) { + public WebSocketHttp3ExperimentalProfile { + java.util.Objects.requireNonNull(quicImplementation, "quicImplementation"); + java.util.Objects.requireNonNull(validatedClients, "validatedClients"); + } +} + ``` + + Implement every listed production file with the exact public names, boundaries and invariants above. Keep raw transport sessions, credentials, payloads, database types and dynamic identifiers out of the public contract unless this task explicitly defines a bounded adapter type. + + - [ ] **Step 4: Run the focused test and the owning suite** + + Run: + + ```bash + ./gradlew :modules:websocket-advanced:websocket-http3-experimental:test --tests 'io.backend.skeleton.websocket.http3.WebSocketHttp3ExperimentalProfileTest' + ./gradlew websocketAdvancedTest + ``` + + Expected: PASS for the focused test and the aggregate Advanced suite. + + - [ ] **Step 5: Commit the independently reviewable change** + + ```bash + git add 'modules/websocket-advanced/websocket-http3-experimental/src/main/java/io/backend/skeleton/websocket/http3/WebSocketHttp3ExperimentalProfile.java' 'modules/websocket-advanced/websocket-http3-experimental/src/test/java/io/backend/skeleton/websocket/http3/WebSocketHttp3ExperimentalProfileTest.java' 'modules/websocket-advanced/websocket-http3-experimental/src/test/java/io/backend/skeleton/websocket/http3/WebSocketHttp3ExperimentalContractTest.java' + git commit -m "feat: http-3-websocket-experimental" + ``` + +### Task 21: GraphQL WebSocket Transport Bridge + + **Files:** + - Create: `modules/websocket-advanced/websocket-graphql-transport-bridge/src/main/java/io/backend/skeleton/websocket/graphql/WebSocketGraphQlTransportBridgePolicy.java` +- Create: `modules/websocket-advanced/websocket-graphql-transport-bridge/src/main/java/io/backend/skeleton/websocket/graphql/WebSocketGraphQlTransportBridge.java` +- Test: `modules/websocket-advanced/websocket-graphql-transport-bridge/src/test/java/io/backend/skeleton/websocket/graphql/WebSocketGraphQlTransportBridgePolicyTest.java` + + **Interfaces:** + - Consumes: Stable Connection Runtime와 GraphQL Platform subscription transport SPI. + - Produces: `graphql-transport-ws` transport bridge without schema/operation semantics. + + **Implementation requirements:** + - ownsGraphQlSemantics=false다. +- GraphQL error·operation·subscription lifecycle을 재구현하지 않는다. +- Stable security·queue·heartbeat budget을 재사용한다. + + - [ ] **Step 1: Write the failing test** + + ```java + package io.backend.skeleton.websocket.graphql; + +class WebSocketGraphQlTransportBridgePolicyTest { + @org.junit.jupiter.api.Test + void recordShapeIsStable() { + var names = java.util.Arrays.stream(WebSocketGraphQlTransportBridgePolicy.class.getRecordComponents()) + .map(java.lang.reflect.RecordComponent::getName) + .toList(); + org.assertj.core.api.Assertions.assertThat(names) + .containsExactly("subprotocol", "ownsGraphQlSemantics", "usesStableConnectionRuntime"); + } +} + ``` + + - [ ] **Step 2: Run the focused test and verify the failure** + + Run: + + ```bash + ./gradlew :modules:websocket-advanced:websocket-graphql-transport-bridge:test --tests 'io.backend.skeleton.websocket.graphql.WebSocketGraphQlTransportBridgePolicyTest' + ``` + + Expected: FAIL because `WebSocketGraphQlTransportBridgePolicy` and its required policy contract do not exist yet. + + - [ ] **Step 3: Implement the smallest complete production contract** + + ```java + package io.backend.skeleton.websocket.graphql; + +public record WebSocketGraphQlTransportBridgePolicy( + String subprotocol, + boolean ownsGraphQlSemantics, + boolean usesStableConnectionRuntime) { + public WebSocketGraphQlTransportBridgePolicy { + java.util.Objects.requireNonNull(subprotocol, "subprotocol"); + } +} + ``` + + Implement every listed production file with the exact public names, boundaries and invariants above. Keep raw transport sessions, credentials, payloads, database types and dynamic identifiers out of the public contract unless this task explicitly defines a bounded adapter type. + + - [ ] **Step 4: Run the focused test and the owning suite** + + Run: + + ```bash + ./gradlew :modules:websocket-advanced:websocket-graphql-transport-bridge:test --tests 'io.backend.skeleton.websocket.graphql.WebSocketGraphQlTransportBridgePolicyTest' + ./gradlew websocketAdvancedTest + ``` + + Expected: PASS for the focused test and the aggregate Advanced suite. + + - [ ] **Step 5: Commit the independently reviewable change** + + ```bash + git add 'modules/websocket-advanced/websocket-graphql-transport-bridge/src/main/java/io/backend/skeleton/websocket/graphql/WebSocketGraphQlTransportBridgePolicy.java' 'modules/websocket-advanced/websocket-graphql-transport-bridge/src/test/java/io/backend/skeleton/websocket/graphql/WebSocketGraphQlTransportBridgePolicyTest.java' 'modules/websocket-advanced/websocket-graphql-transport-bridge/src/main/java/io/backend/skeleton/websocket/graphql/WebSocketGraphQlTransportBridge.java' + git commit -m "feat: graphql-websocket-transport-bridge" + ``` + +### Task 22: Advanced Promotion·Soak·Rollback Gate + + **Files:** + - Create: `modules/websocket-advanced/websocket-advanced-bootstrap/src/main/java/io/backend/skeleton/websocket/release/WebSocketAdvancedPromotionGate.java` +- Create: `docs/superpowers/adr/ADR-WS-002-resume-and-cluster.md` +- Create: `docs/superpowers/adr/ADR-WS-003-stomp-and-broker-relay.md` +- Create: `docs/superpowers/runbooks/websocket-broker-outage.md` +- Create: `docs/superpowers/runbooks/websocket-resume-history-loss.md` +- Create: `docs/superpowers/support/websocket-advanced-support-matrix.md` +- Test: `modules/websocket-advanced/websocket-advanced-bootstrap/src/test/java/io/backend/skeleton/websocket/release/WebSocketAdvancedPromotionGateTest.java` + + **Interfaces:** + - Consumes: Advanced Task 1–21과 Stable Release Gate. + - Produces: feature별 promotion evidence와 rollback runbook. + + **Implementation requirements:** + - Resume·Cluster·STOMP·Compression·H2/H3를 각각 독립 승격한다. +- Stable artifact dependency graph와 wire contract가 바뀌지 않았음을 검증한다. +- 실제 multi-node·broker·browser soak와 rollback을 요구한다. + + - [ ] **Step 1: Write the failing test** + + ```java + package io.backend.skeleton.websocket.release; + +class WebSocketAdvancedPromotionGateTest { + @org.junit.jupiter.api.Test + void recordShapeIsStable() { + var names = java.util.Arrays.stream(WebSocketAdvancedPromotionGate.class.getRecordComponents()) + .map(java.lang.reflect.RecordComponent::getName) + .toList(); + org.assertj.core.api.Assertions.assertThat(names) + .containsExactly("requiredSuites", "minimumSoak", "rollbackValidated", "stableArtifactUnchanged"); + } +} + ``` + + - [ ] **Step 2: Run the focused test and verify the failure** + + Run: + + ```bash + ./gradlew :modules:websocket-advanced:websocket-advanced-bootstrap:test --tests 'io.backend.skeleton.websocket.release.WebSocketAdvancedPromotionGateTest' + ``` + + Expected: FAIL because `WebSocketAdvancedPromotionGate` and its required policy contract do not exist yet. + + - [ ] **Step 3: Implement the smallest complete production contract** + + ```java + package io.backend.skeleton.websocket.release; + +public record WebSocketAdvancedPromotionGate( + java.util.Set requiredSuites, + java.time.Duration minimumSoak, + boolean rollbackValidated, + boolean stableArtifactUnchanged) { + public WebSocketAdvancedPromotionGate { + java.util.Objects.requireNonNull(requiredSuites, "requiredSuites"); + java.util.Objects.requireNonNull(minimumSoak, "minimumSoak"); + } +} + ``` + + Implement every listed production file with the exact public names, boundaries and invariants above. Keep raw transport sessions, credentials, payloads, database types and dynamic identifiers out of the public contract unless this task explicitly defines a bounded adapter type. + + - [ ] **Step 4: Run the focused test and the owning suite** + + Run: + + ```bash + ./gradlew :modules:websocket-advanced:websocket-advanced-bootstrap:test --tests 'io.backend.skeleton.websocket.release.WebSocketAdvancedPromotionGateTest' + ./gradlew websocketAdvancedTest + ``` + + Expected: PASS for the focused test and the aggregate Advanced suite. + + - [ ] **Step 5: Commit the independently reviewable change** + + ```bash + git add 'modules/websocket-advanced/websocket-advanced-bootstrap/src/main/java/io/backend/skeleton/websocket/release/WebSocketAdvancedPromotionGate.java' 'modules/websocket-advanced/websocket-advanced-bootstrap/src/test/java/io/backend/skeleton/websocket/release/WebSocketAdvancedPromotionGateTest.java' 'docs/superpowers/adr/ADR-WS-002-resume-and-cluster.md' 'docs/superpowers/adr/ADR-WS-003-stomp-and-broker-relay.md' 'docs/superpowers/runbooks/websocket-broker-outage.md' 'docs/superpowers/runbooks/websocket-resume-history-loss.md' 'docs/superpowers/support/websocket-advanced-support-matrix.md' + git commit -m "feat: advanced-promotion-soak-rollback-gate" + ``` diff --git a/docs/websocket-superpowers-package/docs/superpowers/plans/2026-08-14-websocket-realtime-connection-platform-implementation-plan.md b/docs/websocket-superpowers-package/docs/superpowers/plans/2026-08-14-websocket-realtime-connection-platform-implementation-plan.md new file mode 100644 index 00000000..6ee91e05 --- /dev/null +++ b/docs/websocket-superpowers-package/docs/superpowers/plans/2026-08-14-websocket-realtime-connection-platform-implementation-plan.md @@ -0,0 +1,4293 @@ +# WebSocket 실시간 양방향 연결 실행 플랫폼 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Spring Boot 4.1을 실행 엔진으로 사용하면서 Raw Typed JSON, 안전한 browser handshake, 세 축 실행 증거, 상태 변경 idempotency, bounded outbound queue, serialized writer, heartbeat·drain, Servlet·WebFlux adapter와 실제 Browser·Nginx 검증을 제공하는 Stable WebSocket 실시간 연결 플랫폼을 구축한다. + +**Architecture:** `websocket-core-api`가 endpoint·context·evidence·budget·error의 transport-neutral 계약을 소유하고 `websocket-protocol`, `websocket-session`, `websocket-security`, `websocket-resilience`가 Typed JSON 실행과 연결 수명을 구현한다. `websocket-servlet`과 `websocket-webflux`는 동일 Stable 계약을 Tomcat·Jetty와 Reactor Netty에 매핑하며, 상태 변경의 강한 완료 증거는 Application transaction과 결합 가능한 Result Ledger SPI가 제공한다. STOMP·Broker Relay·Resume·Cluster·Binary Codec·Compression·SockJS·HTTP/2·3은 Advanced 계획으로 격리한다. + +**Tech Stack:** Java 21, Gradle Kotlin DSL, Spring Boot 4.1 BOM, Spring Framework 7.0, Spring WebSocket, Spring WebFlux, Tomcat 11, Jetty 12.1, Reactor Netty, Spring Security, Jackson, Micrometer Observation, OpenTelemetry bridge, Redis ticket capability, JUnit 5, AssertJ, ArchUnit, Testcontainers, Toxiproxy, Nginx, Playwright. + +## Global Constraints + +- Java runtime은 `21`이다. +- dependency version의 Source of Truth는 Spring Boot `4.1` BOM이다. +- Stable protocol은 `hyeonworks.realtime.v1.json`이다. +- Stable codec은 UTF-8 strict JSON이다. +- Stable starter는 STOMP·Broker Relay·Cluster·Resume·Binary Codec·Compression·SockJS·HTTP/2·3을 의존하지 않는다. +- `websocket-core-api`는 Servlet, Spring WebSocket, Reactor, Netty, STOMP에 의존하지 않는다. +- Upgrade 이전 HTTP 오류는 기존 `web` Problem contract를 사용한다. +- Upgrade 이후에는 HTTP Problem으로 변경하지 않고 Typed Error 또는 Close를 사용한다. +- Cookie 인증 endpoint는 exact Origin allowlist를 요구한다. +- long-lived access token query parameter 인증을 지원하지 않는다. +- Connection ticket은 short TTL·one-time atomic consume·actor/tenant/endpoint/origin binding을 요구한다. +- 모든 production endpoint는 subprotocol과 positive resource budget을 가진다. +- Inbound, Outbound, Connection evidence를 한 enum으로 평탄화하지 않는다. +- `sendMessage()`·reactive send completion을 Client receive/apply evidence로 승격하지 않는다. +- 상태 변경 Command는 connection sequence가 아닌 idempotency key와 semantic fingerprint를 사용한다. +- Commit 여부가 불명확하면 자동 재실행하지 않는다. +- 한 Session의 outbound write는 하나의 serialized writer가 수행한다. +- queue는 message count와 bytes 모두 bounded다. +- global buffered bytes hard limit가 존재한다. +- critical response/event를 silent drop하지 않는다. +- heartbeat interval < timeout < Nginx proxy read timeout을 강제한다. +- credential 만료·권한 회수·max connection age에서 connection을 종료한다. +- Tomcat이 Stable 기본이고 Jetty가 Servlet compatibility lane이다. +- WebFlux Stable profile은 Reactor Netty이며 event-loop blocking을 금지한다. +- MVC와 WebFlux Starter 동시 활성화를 금지한다. +- 실제 Chromium·Firefox·WebKit, Nginx TLS, Tomcat·Jetty·Reactor Netty가 Stable gate다. +- Metric tag에 session/message/user/tenant/resource/idempotency raw ID를 넣지 않는다. +- Stable module root는 `modules/websocket`이다. +- Root package는 `io.backend.skeleton.websocket`이다. +- 모든 task는 red-green TDD와 독립 commit으로 끝난다. +- 실제 저장소 구조가 예상 경로와 다르면 파일 경로만 매핑하고 공개 계약·불변 조건·테스트 의미는 변경하지 않는다. + +--- + +## Execution Baseline + +```text +Stable Task 1–53 +→ Stable Release Gate +→ Advanced Task 1–22 +``` + +## Delivery Phases + +| Phase | Tasks | 독립 검증 결과 | +|---|---:|---| +| Foundation | 1–11 | 모듈·ID·Protocol·Context·Budget·Evidence·Error | +| Application Reliability | 12–15 | Handler·Correlation·Idempotency·Result Ledger | +| Security·Handshake | 16–21 | Auth·Origin·Ticket·Admission·Registry·Architecture | +| Protocol Runtime | 22–31 | Assembly·Authorization·Queue·Writer·Ordering·Heartbeat | +| Lifecycle·Transport | 32–39 | Credential expiry·Drain·Servlet·Tomcat·Jetty·WebFlux | +| Operations | 40–47 | Observation·Admin·Config·Starter·Nginx·Browser fixture | +| Verification | 48–53 | Browser Matrix·Fault·Performance·Security·Restart·Release | + +--- +### Task 1: Gradle Stable 모듈 그래프와 집계 Task + + **Files:** + - Create: `modules/websocket/websocket-core-api/src/main/java/io/backend/skeleton/websocket/core/WebSocketStableModuleCatalog.java` +- Create: `modules/websocket/build.gradle.kts` +- Create: `modules/websocket/websocket-core-api/build.gradle.kts` +- Create: `modules/websocket/websocket-protocol/build.gradle.kts` +- Create: `modules/websocket/websocket-session/build.gradle.kts` +- Create: `modules/websocket/websocket-security/build.gradle.kts` +- Create: `modules/websocket/websocket-resilience/build.gradle.kts` +- Create: `modules/websocket/websocket-observability/build.gradle.kts` +- Create: `modules/websocket/websocket-servlet/build.gradle.kts` +- Create: `modules/websocket/websocket-webflux/build.gradle.kts` +- Create: `modules/websocket/websocket-admin/build.gradle.kts` +- Create: `modules/websocket/websocket-spring-boot-starter-mvc/build.gradle.kts` +- Create: `modules/websocket/websocket-spring-boot-starter-webflux/build.gradle.kts` +- Create: `modules/websocket/websocket-testkit-core/build.gradle.kts` +- Create: `modules/websocket/websocket-testkit-servlet/build.gradle.kts` +- Create: `modules/websocket/websocket-testkit-webflux/build.gradle.kts` +- Create: `modules/websocket/websocket-testkit-browser/build.gradle.kts` +- Create: `modules/websocket/websocket-testkit-proxy/build.gradle.kts` +- Create: `modules/websocket/websocket-testkit-fault/build.gradle.kts` +- Test: `modules/websocket/websocket-core-api/src/test/java/io/backend/skeleton/websocket/core/WebSocketStableModuleCatalogTest.java` + + **Interfaces:** + - Consumes: Root Gradle settings와 Spring Boot 4.1 BOM. + - Produces: 정확한 Stable module catalog와 `websocketStableTest`, `websocketBrowserTest`, `websocketProxyTest`, `websocketFaultTest`, `websocketPerformanceTest` 집계 Task. + + **Implementation requirements:** + - Stable module 목록을 설계서와 동일하게 고정한다. +- Stable Starter가 `modules/websocket-advanced`를 참조하면 build를 실패시킨다. +- `websocket-core-api`에는 Spring WebSocket·Servlet·Reactor·Netty 의존성을 넣지 않는다. +- 모든 runtime 버전은 Boot 4.1 BOM이 소유한다. + + - [ ] **Step 1: Write the failing test** + + ```java + package io.backend.skeleton.websocket.core; + +class WebSocketStableModuleCatalogTest { + @org.junit.jupiter.api.Test + void recordShapeIsStable() { + var names = java.util.Arrays.stream(WebSocketStableModuleCatalog.class.getRecordComponents()) + .map(java.lang.reflect.RecordComponent::getName) + .toList(); + org.assertj.core.api.Assertions.assertThat(names) + .containsExactly("modules"); + } +} + ``` + + - [ ] **Step 2: Run the focused test and verify the failure** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-core-api:test --tests 'io.backend.skeleton.websocket.core.WebSocketStableModuleCatalogTest' + ``` + + Expected: FAIL because `WebSocketStableModuleCatalog` and its required policy contract do not exist yet. + + - [ ] **Step 3: Implement the smallest complete production contract** + + ```java + package io.backend.skeleton.websocket.core; + +public record WebSocketStableModuleCatalog( + java.util.Set modules) { + public WebSocketStableModuleCatalog { + java.util.Objects.requireNonNull(modules, "modules"); + } +} + ``` + + Implement every listed production file with the exact public names, boundaries and invariants above. Keep raw transport sessions, credentials, payloads, database types and dynamic identifiers out of the public contract unless this task explicitly defines a bounded adapter type. + + - [ ] **Step 4: Run the focused test and the owning suite** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-core-api:test --tests 'io.backend.skeleton.websocket.core.WebSocketStableModuleCatalogTest' + ./gradlew websocketStableTest + ``` + + Expected: PASS for the focused test and the aggregate Stable suite. + + - [ ] **Step 5: Commit the independently reviewable change** + + ```bash + git add 'modules/websocket/websocket-core-api/src/main/java/io/backend/skeleton/websocket/core/WebSocketStableModuleCatalog.java' 'modules/websocket/websocket-core-api/src/test/java/io/backend/skeleton/websocket/core/WebSocketStableModuleCatalogTest.java' 'modules/websocket/build.gradle.kts' 'modules/websocket/websocket-core-api/build.gradle.kts' 'modules/websocket/websocket-protocol/build.gradle.kts' 'modules/websocket/websocket-session/build.gradle.kts' 'modules/websocket/websocket-security/build.gradle.kts' 'modules/websocket/websocket-resilience/build.gradle.kts' 'modules/websocket/websocket-observability/build.gradle.kts' 'modules/websocket/websocket-servlet/build.gradle.kts' 'modules/websocket/websocket-webflux/build.gradle.kts' 'modules/websocket/websocket-admin/build.gradle.kts' 'modules/websocket/websocket-spring-boot-starter-mvc/build.gradle.kts' 'modules/websocket/websocket-spring-boot-starter-webflux/build.gradle.kts' 'modules/websocket/websocket-testkit-core/build.gradle.kts' 'modules/websocket/websocket-testkit-servlet/build.gradle.kts' 'modules/websocket/websocket-testkit-webflux/build.gradle.kts' 'modules/websocket/websocket-testkit-browser/build.gradle.kts' 'modules/websocket/websocket-testkit-proxy/build.gradle.kts' 'modules/websocket/websocket-testkit-fault/build.gradle.kts' + git commit -m "build: establish websocket stable module graph" + ``` + +### Task 2: Core 식별자와 Endpoint 이름 + + **Files:** + - Create: `modules/websocket/websocket-core-api/src/main/java/io/backend/skeleton/websocket/core/WebSocketEndpointName.java` +- Create: `modules/websocket/websocket-core-api/src/main/java/io/backend/skeleton/websocket/core/WebSocketConnectionId.java` +- Create: `modules/websocket/websocket-core-api/src/main/java/io/backend/skeleton/websocket/core/WebSocketSessionId.java` +- Create: `modules/websocket/websocket-core-api/src/main/java/io/backend/skeleton/websocket/core/WebSocketNodeId.java` +- Test: `modules/websocket/websocket-core-api/src/test/java/io/backend/skeleton/websocket/core/WebSocketEndpointNameTest.java` + + **Interfaces:** + - Consumes: Task 1의 Stable module graph. + - Produces: 공백·제어문자·무제한 길이를 거부하는 bounded identifiers. + + **Implementation requirements:** + - Endpoint name은 정규식 `[a-z][a-z0-9-]{1,63}`을 사용한다. +- Connection·Session ID는 외부 credential이나 DB key 의미를 포함하지 않는다. +- ID 원문은 Metric tag로 사용하지 않는다. + + - [ ] **Step 1: Write the failing test** + + ```java + package io.backend.skeleton.websocket.core; + +class WebSocketEndpointNameTest { + @org.junit.jupiter.api.Test + void recordShapeIsStable() { + var names = java.util.Arrays.stream(WebSocketEndpointName.class.getRecordComponents()) + .map(java.lang.reflect.RecordComponent::getName) + .toList(); + org.assertj.core.api.Assertions.assertThat(names) + .containsExactly("value"); + } +} + ``` + + - [ ] **Step 2: Run the focused test and verify the failure** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-core-api:test --tests 'io.backend.skeleton.websocket.core.WebSocketEndpointNameTest' + ``` + + Expected: FAIL because `WebSocketEndpointName` and its required policy contract do not exist yet. + + - [ ] **Step 3: Implement the smallest complete production contract** + + ```java + package io.backend.skeleton.websocket.core; + +public record WebSocketEndpointName( + String value) { + public WebSocketEndpointName { + java.util.Objects.requireNonNull(value, "value"); + } +} + ``` + + Implement every listed production file with the exact public names, boundaries and invariants above. Keep raw transport sessions, credentials, payloads, database types and dynamic identifiers out of the public contract unless this task explicitly defines a bounded adapter type. + + - [ ] **Step 4: Run the focused test and the owning suite** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-core-api:test --tests 'io.backend.skeleton.websocket.core.WebSocketEndpointNameTest' + ./gradlew websocketStableTest + ``` + + Expected: PASS for the focused test and the aggregate Stable suite. + + - [ ] **Step 5: Commit the independently reviewable change** + + ```bash + git add 'modules/websocket/websocket-core-api/src/main/java/io/backend/skeleton/websocket/core/WebSocketEndpointName.java' 'modules/websocket/websocket-core-api/src/test/java/io/backend/skeleton/websocket/core/WebSocketEndpointNameTest.java' 'modules/websocket/websocket-core-api/src/main/java/io/backend/skeleton/websocket/core/WebSocketConnectionId.java' 'modules/websocket/websocket-core-api/src/main/java/io/backend/skeleton/websocket/core/WebSocketSessionId.java' 'modules/websocket/websocket-core-api/src/main/java/io/backend/skeleton/websocket/core/WebSocketNodeId.java' + git commit -m "feat: core-endpoint" + ``` + +### Task 3: Protocol 이름·버전·Codec Profile + + **Files:** + - Create: `modules/websocket/websocket-protocol/src/main/java/io/backend/skeleton/websocket/protocol/WebSocketProtocolProfile.java` +- Create: `modules/websocket/websocket-protocol/src/main/java/io/backend/skeleton/websocket/protocol/WebSocketSubprotocolName.java` +- Create: `modules/websocket/websocket-protocol/src/main/java/io/backend/skeleton/websocket/protocol/WebSocketCodecProfile.java` +- Test: `modules/websocket/websocket-protocol/src/test/java/io/backend/skeleton/websocket/protocol/WebSocketProtocolProfileTest.java` + + **Interfaces:** + - Consumes: Task 2의 Endpoint identifier. + - Produces: `hyeonworks.realtime.v1.json`을 표현하는 bounded protocol profile. + + **Implementation requirements:** + - Production Typed Endpoint는 지원 subprotocol이 없으면 거부한다. +- Stable codec은 JSON 하나로 고정한다. +- subprotocol 없는 fallback은 local compatibility profile에서만 허용한다. + + - [ ] **Step 1: Write the failing test** + + ```java + package io.backend.skeleton.websocket.protocol; + +class WebSocketProtocolProfileTest { + @org.junit.jupiter.api.Test + void recordShapeIsStable() { + var names = java.util.Arrays.stream(WebSocketProtocolProfile.class.getRecordComponents()) + .map(java.lang.reflect.RecordComponent::getName) + .toList(); + org.assertj.core.api.Assertions.assertThat(names) + .containsExactly("name", "majorVersion", "codec"); + } +} + ``` + + - [ ] **Step 2: Run the focused test and verify the failure** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-protocol:test --tests 'io.backend.skeleton.websocket.protocol.WebSocketProtocolProfileTest' + ``` + + Expected: FAIL because `WebSocketProtocolProfile` and its required policy contract do not exist yet. + + - [ ] **Step 3: Implement the smallest complete production contract** + + ```java + package io.backend.skeleton.websocket.protocol; + +public record WebSocketProtocolProfile( + String name, + int majorVersion, + String codec) { + public WebSocketProtocolProfile { + java.util.Objects.requireNonNull(name, "name"); + java.util.Objects.requireNonNull(codec, "codec"); + } +} + ``` + + Implement every listed production file with the exact public names, boundaries and invariants above. Keep raw transport sessions, credentials, payloads, database types and dynamic identifiers out of the public contract unless this task explicitly defines a bounded adapter type. + + - [ ] **Step 4: Run the focused test and the owning suite** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-protocol:test --tests 'io.backend.skeleton.websocket.protocol.WebSocketProtocolProfileTest' + ./gradlew websocketStableTest + ``` + + Expected: PASS for the focused test and the aggregate Stable suite. + + - [ ] **Step 5: Commit the independently reviewable change** + + ```bash + git add 'modules/websocket/websocket-protocol/src/main/java/io/backend/skeleton/websocket/protocol/WebSocketProtocolProfile.java' 'modules/websocket/websocket-protocol/src/test/java/io/backend/skeleton/websocket/protocol/WebSocketProtocolProfileTest.java' 'modules/websocket/websocket-protocol/src/main/java/io/backend/skeleton/websocket/protocol/WebSocketSubprotocolName.java' 'modules/websocket/websocket-protocol/src/main/java/io/backend/skeleton/websocket/protocol/WebSocketCodecProfile.java' + git commit -m "feat: protocol-codec-profile" + ``` + +### Task 4: Endpoint 실행 Profile + + **Files:** + - Create: `modules/websocket/websocket-core-api/src/main/java/io/backend/skeleton/websocket/core/WebSocketEndpointProfile.java` +- Create: `modules/websocket/websocket-core-api/src/main/java/io/backend/skeleton/websocket/core/WebSocketStackProfile.java` +- Create: `modules/websocket/websocket-core-api/src/main/java/io/backend/skeleton/websocket/core/WebSocketEndpointCatalog.java` +- Test: `modules/websocket/websocket-core-api/src/test/java/io/backend/skeleton/websocket/core/WebSocketEndpointProfileTest.java` + + **Interfaces:** + - Consumes: Task 2·3의 identifiers와 protocol profile. + - Produces: Endpoint path·protocol·auth·budget의 immutable catalog. + + **Implementation requirements:** + - Endpoint path는 canonical absolute path다. +- 동적 endpoint 등록은 startup 이후 허용하지 않는다. +- 모든 production endpoint는 positive connection budget을 가진다. + + - [ ] **Step 1: Write the failing test** + + ```java + package io.backend.skeleton.websocket.core; + +class WebSocketEndpointProfileTest { + @org.junit.jupiter.api.Test + void recordShapeIsStable() { + var names = java.util.Arrays.stream(WebSocketEndpointProfile.class.getRecordComponents()) + .map(java.lang.reflect.RecordComponent::getName) + .toList(); + org.assertj.core.api.Assertions.assertThat(names) + .containsExactly("endpointName", "path", "subprotocols", "authenticationProfile", "maxConnections"); + } +} + ``` + + - [ ] **Step 2: Run the focused test and verify the failure** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-core-api:test --tests 'io.backend.skeleton.websocket.core.WebSocketEndpointProfileTest' + ``` + + Expected: FAIL because `WebSocketEndpointProfile` and its required policy contract do not exist yet. + + - [ ] **Step 3: Implement the smallest complete production contract** + + ```java + package io.backend.skeleton.websocket.core; + +public record WebSocketEndpointProfile( + String endpointName, + String path, + java.util.Set subprotocols, + String authenticationProfile, + long maxConnections) { + public WebSocketEndpointProfile { + java.util.Objects.requireNonNull(endpointName, "endpointName"); + java.util.Objects.requireNonNull(path, "path"); + java.util.Objects.requireNonNull(subprotocols, "subprotocols"); + java.util.Objects.requireNonNull(authenticationProfile, "authenticationProfile"); + } +} + ``` + + Implement every listed production file with the exact public names, boundaries and invariants above. Keep raw transport sessions, credentials, payloads, database types and dynamic identifiers out of the public contract unless this task explicitly defines a bounded adapter type. + + - [ ] **Step 4: Run the focused test and the owning suite** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-core-api:test --tests 'io.backend.skeleton.websocket.core.WebSocketEndpointProfileTest' + ./gradlew websocketStableTest + ``` + + Expected: PASS for the focused test and the aggregate Stable suite. + + - [ ] **Step 5: Commit the independently reviewable change** + + ```bash + git add 'modules/websocket/websocket-core-api/src/main/java/io/backend/skeleton/websocket/core/WebSocketEndpointProfile.java' 'modules/websocket/websocket-core-api/src/test/java/io/backend/skeleton/websocket/core/WebSocketEndpointProfileTest.java' 'modules/websocket/websocket-core-api/src/main/java/io/backend/skeleton/websocket/core/WebSocketStackProfile.java' 'modules/websocket/websocket-core-api/src/main/java/io/backend/skeleton/websocket/core/WebSocketEndpointCatalog.java' + git commit -m "feat: endpoint-profile" + ``` + +### Task 5: Connection 상태와 immutable Context + + **Files:** + - Create: `modules/websocket/websocket-core-api/src/main/java/io/backend/skeleton/websocket/core/WebSocketConnectionState.java` +- Create: `modules/websocket/websocket-core-api/src/main/java/io/backend/skeleton/websocket/core/WebSocketConnectionContext.java` +- Create: `modules/websocket/websocket-core-api/src/main/java/io/backend/skeleton/websocket/core/WebSocketCredentialExpiry.java` +- Test: `modules/websocket/websocket-core-api/src/test/java/io/backend/skeleton/websocket/core/WebSocketConnectionStateTest.java` + + **Interfaces:** + - Consumes: Endpoint catalog와 security actor context. + - Produces: transport session과 분리된 immutable connection context와 상태 전이. + + **Implementation requirements:** + - Context에는 actor·tenant 원문 대신 bounded reference/fingerprint를 사용한다. +- OPEN 이후 endpoint·protocol·actor·tenant를 변경하지 않는다. +- state transition은 단방향 검증을 통과해야 한다. + + - [ ] **Step 1: Write the failing test** + + ```java + package io.backend.skeleton.websocket.core; + +class WebSocketConnectionStateTest { + @org.junit.jupiter.api.Test + void valuesAreStable() { + org.assertj.core.api.Assertions.assertThat(WebSocketConnectionState.values()) + .extracting(java.lang.Enum::name) + .containsExactly("CONNECTING", "HANDSHAKE_ACCEPTED", "AUTHENTICATED", "OPEN", "DRAINING", "CLOSING", "CLOSED", "ABNORMAL"); + } +} + ``` + + - [ ] **Step 2: Run the focused test and verify the failure** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-core-api:test --tests 'io.backend.skeleton.websocket.core.WebSocketConnectionStateTest' + ``` + + Expected: FAIL because `WebSocketConnectionState` and its required policy contract do not exist yet. + + - [ ] **Step 3: Implement the smallest complete production contract** + + ```java + package io.backend.skeleton.websocket.core; + +public enum WebSocketConnectionState { + CONNECTING, + HANDSHAKE_ACCEPTED, + AUTHENTICATED, + OPEN, + DRAINING, + CLOSING, + CLOSED, + ABNORMAL +} + ``` + + Implement every listed production file with the exact public names, boundaries and invariants above. Keep raw transport sessions, credentials, payloads, database types and dynamic identifiers out of the public contract unless this task explicitly defines a bounded adapter type. + + - [ ] **Step 4: Run the focused test and the owning suite** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-core-api:test --tests 'io.backend.skeleton.websocket.core.WebSocketConnectionStateTest' + ./gradlew websocketStableTest + ``` + + Expected: PASS for the focused test and the aggregate Stable suite. + + - [ ] **Step 5: Commit the independently reviewable change** + + ```bash + git add 'modules/websocket/websocket-core-api/src/main/java/io/backend/skeleton/websocket/core/WebSocketConnectionState.java' 'modules/websocket/websocket-core-api/src/test/java/io/backend/skeleton/websocket/core/WebSocketConnectionStateTest.java' 'modules/websocket/websocket-core-api/src/main/java/io/backend/skeleton/websocket/core/WebSocketConnectionContext.java' 'modules/websocket/websocket-core-api/src/main/java/io/backend/skeleton/websocket/core/WebSocketCredentialExpiry.java' + git commit -m "feat: connection-immutable-context" + ``` + +### Task 6: Typed Message Family와 Envelope + + **Files:** + - Create: `modules/websocket/websocket-protocol/src/main/java/io/backend/skeleton/websocket/protocol/WebSocketMessageFamily.java` +- Create: `modules/websocket/websocket-protocol/src/main/java/io/backend/skeleton/websocket/protocol/WebSocketEnvelope.java` +- Create: `modules/websocket/websocket-protocol/src/main/java/io/backend/skeleton/websocket/protocol/WebSocketMessageId.java` +- Create: `modules/websocket/websocket-protocol/src/main/java/io/backend/skeleton/websocket/protocol/WebSocketCorrelationId.java` +- Test: `modules/websocket/websocket-protocol/src/test/java/io/backend/skeleton/websocket/protocol/WebSocketMessageFamilyTest.java` + + **Interfaces:** + - Consumes: Protocol profile와 core identifiers. + - Produces: Family별 optional field 규칙을 가진 typed envelope. + + **Implementation requirements:** + - Java FQCN을 message type으로 사용하지 않는다. +- payload `Map`와 Entity·Document 직접 직렬화를 금지한다. +- sequence·streamId는 ordering이 필요한 family에만 허용한다. +- expiresAt이 지난 Command는 handler 전에 거부한다. + + - [ ] **Step 1: Write the failing test** + + ```java + package io.backend.skeleton.websocket.protocol; + +class WebSocketMessageFamilyTest { + @org.junit.jupiter.api.Test + void valuesAreStable() { + org.assertj.core.api.Assertions.assertThat(WebSocketMessageFamily.values()) + .extracting(java.lang.Enum::name) + .containsExactly("REQUEST", "RESPONSE", "COMMAND", "EVENT", "ERROR", "PING", "PONG", "CANCEL", "COMPLETE"); + } +} + ``` + + - [ ] **Step 2: Run the focused test and verify the failure** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-protocol:test --tests 'io.backend.skeleton.websocket.protocol.WebSocketMessageFamilyTest' + ``` + + Expected: FAIL because `WebSocketMessageFamily` and its required policy contract do not exist yet. + + - [ ] **Step 3: Implement the smallest complete production contract** + + ```java + package io.backend.skeleton.websocket.protocol; + +public enum WebSocketMessageFamily { + REQUEST, + RESPONSE, + COMMAND, + EVENT, + ERROR, + PING, + PONG, + CANCEL, + COMPLETE +} + ``` + + Implement every listed production file with the exact public names, boundaries and invariants above. Keep raw transport sessions, credentials, payloads, database types and dynamic identifiers out of the public contract unless this task explicitly defines a bounded adapter type. + + - [ ] **Step 4: Run the focused test and the owning suite** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-protocol:test --tests 'io.backend.skeleton.websocket.protocol.WebSocketMessageFamilyTest' + ./gradlew websocketStableTest + ``` + + Expected: PASS for the focused test and the aggregate Stable suite. + + - [ ] **Step 5: Commit the independently reviewable change** + + ```bash + git add 'modules/websocket/websocket-protocol/src/main/java/io/backend/skeleton/websocket/protocol/WebSocketMessageFamily.java' 'modules/websocket/websocket-protocol/src/test/java/io/backend/skeleton/websocket/protocol/WebSocketMessageFamilyTest.java' 'modules/websocket/websocket-protocol/src/main/java/io/backend/skeleton/websocket/protocol/WebSocketEnvelope.java' 'modules/websocket/websocket-protocol/src/main/java/io/backend/skeleton/websocket/protocol/WebSocketMessageId.java' 'modules/websocket/websocket-protocol/src/main/java/io/backend/skeleton/websocket/protocol/WebSocketCorrelationId.java' + git commit -m "feat: typed-message-family-envelope" + ``` + +### Task 7: Message Catalog와 Schema Version + + **Files:** + - Create: `modules/websocket/websocket-protocol/src/main/java/io/backend/skeleton/websocket/protocol/WebSocketMessageDescriptor.java` +- Create: `modules/websocket/websocket-protocol/src/main/java/io/backend/skeleton/websocket/protocol/WebSocketMessageCatalog.java` +- Create: `modules/websocket/websocket-protocol/src/main/java/io/backend/skeleton/websocket/protocol/WebSocketSchemaVersion.java` +- Create: `modules/websocket/websocket-protocol/src/main/java/io/backend/skeleton/websocket/protocol/WebSocketCatalogFingerprint.java` +- Test: `modules/websocket/websocket-protocol/src/test/java/io/backend/skeleton/websocket/protocol/WebSocketMessageDescriptorTest.java` + + **Interfaces:** + - Consumes: Typed message family와 protocol profile. + - Produces: type+version keyed immutable catalog와 schema fingerprint. + + **Implementation requirements:** + - 동일 type/version의 wire schema를 변경하지 않는다. +- unknown type/version은 Application handler에 도달하지 않는다. +- Catalog duplicate와 unsupported codec은 startup failure다. + + - [ ] **Step 1: Write the failing test** + + ```java + package io.backend.skeleton.websocket.protocol; + +class WebSocketMessageDescriptorTest { + @org.junit.jupiter.api.Test + void recordShapeIsStable() { + var names = java.util.Arrays.stream(WebSocketMessageDescriptor.class.getRecordComponents()) + .map(java.lang.reflect.RecordComponent::getName) + .toList(); + org.assertj.core.api.Assertions.assertThat(names) + .containsExactly("type", "version", "family", "payloadClassName"); + } +} + ``` + + - [ ] **Step 2: Run the focused test and verify the failure** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-protocol:test --tests 'io.backend.skeleton.websocket.protocol.WebSocketMessageDescriptorTest' + ``` + + Expected: FAIL because `WebSocketMessageDescriptor` and its required policy contract do not exist yet. + + - [ ] **Step 3: Implement the smallest complete production contract** + + ```java + package io.backend.skeleton.websocket.protocol; + +public record WebSocketMessageDescriptor( + String type, + int version, + String family, + String payloadClassName) { + public WebSocketMessageDescriptor { + java.util.Objects.requireNonNull(type, "type"); + java.util.Objects.requireNonNull(family, "family"); + java.util.Objects.requireNonNull(payloadClassName, "payloadClassName"); + } +} + ``` + + Implement every listed production file with the exact public names, boundaries and invariants above. Keep raw transport sessions, credentials, payloads, database types and dynamic identifiers out of the public contract unless this task explicitly defines a bounded adapter type. + + - [ ] **Step 4: Run the focused test and the owning suite** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-protocol:test --tests 'io.backend.skeleton.websocket.protocol.WebSocketMessageDescriptorTest' + ./gradlew websocketStableTest + ``` + + Expected: PASS for the focused test and the aggregate Stable suite. + + - [ ] **Step 5: Commit the independently reviewable change** + + ```bash + git add 'modules/websocket/websocket-protocol/src/main/java/io/backend/skeleton/websocket/protocol/WebSocketMessageDescriptor.java' 'modules/websocket/websocket-protocol/src/test/java/io/backend/skeleton/websocket/protocol/WebSocketMessageDescriptorTest.java' 'modules/websocket/websocket-protocol/src/main/java/io/backend/skeleton/websocket/protocol/WebSocketMessageCatalog.java' 'modules/websocket/websocket-protocol/src/main/java/io/backend/skeleton/websocket/protocol/WebSocketSchemaVersion.java' 'modules/websocket/websocket-protocol/src/main/java/io/backend/skeleton/websocket/protocol/WebSocketCatalogFingerprint.java' + git commit -m "feat: message-catalog-schema-version" + ``` + +### Task 8: Strict JSON Codec와 Wire Type Manifest + + **Files:** + - Create: `modules/websocket/websocket-protocol/src/main/java/io/backend/skeleton/websocket/protocol/WebSocketJsonWireManifest.java` +- Create: `modules/websocket/websocket-protocol/src/main/java/io/backend/skeleton/websocket/protocol/StrictWebSocketJsonCodec.java` +- Create: `modules/websocket/websocket-protocol/src/main/java/io/backend/skeleton/websocket/protocol/WebSocketWireTypeManifest.java` +- Test: `modules/websocket/websocket-protocol/src/test/java/io/backend/skeleton/websocket/protocol/WebSocketJsonWireManifestTest.java` + + **Interfaces:** + - Consumes: Message catalog와 Boot-managed Jackson. + - Produces: duplicate key·unknown enum·trailing token·polymorphic escape를 차단하는 JSON codec. + + **Implementation requirements:** + - Stable JSON은 UTF-8만 허용한다. +- unknown message field와 duplicate key를 기본 거부한다. +- Java native serialization과 default typing을 활성화하지 않는다. + + - [ ] **Step 1: Write the failing test** + + ```java + package io.backend.skeleton.websocket.protocol; + +class WebSocketJsonWireManifestTest { + @org.junit.jupiter.api.Test + void recordShapeIsStable() { + var names = java.util.Arrays.stream(WebSocketJsonWireManifest.class.getRecordComponents()) + .map(java.lang.reflect.RecordComponent::getName) + .toList(); + org.assertj.core.api.Assertions.assertThat(names) + .containsExactly("maxDepth", "maxArrayElements", "maxStringBytes", "rejectUnknownFields"); + } +} + ``` + + - [ ] **Step 2: Run the focused test and verify the failure** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-protocol:test --tests 'io.backend.skeleton.websocket.protocol.WebSocketJsonWireManifestTest' + ``` + + Expected: FAIL because `WebSocketJsonWireManifest` and its required policy contract do not exist yet. + + - [ ] **Step 3: Implement the smallest complete production contract** + + ```java + package io.backend.skeleton.websocket.protocol; + +public record WebSocketJsonWireManifest( + int maxDepth, + int maxArrayElements, + int maxStringBytes, + boolean rejectUnknownFields) { + public WebSocketJsonWireManifest { + + } +} + ``` + + Implement every listed production file with the exact public names, boundaries and invariants above. Keep raw transport sessions, credentials, payloads, database types and dynamic identifiers out of the public contract unless this task explicitly defines a bounded adapter type. + + - [ ] **Step 4: Run the focused test and the owning suite** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-protocol:test --tests 'io.backend.skeleton.websocket.protocol.WebSocketJsonWireManifestTest' + ./gradlew websocketStableTest + ``` + + Expected: PASS for the focused test and the aggregate Stable suite. + + - [ ] **Step 5: Commit the independently reviewable change** + + ```bash + git add 'modules/websocket/websocket-protocol/src/main/java/io/backend/skeleton/websocket/protocol/WebSocketJsonWireManifest.java' 'modules/websocket/websocket-protocol/src/test/java/io/backend/skeleton/websocket/protocol/WebSocketJsonWireManifestTest.java' 'modules/websocket/websocket-protocol/src/main/java/io/backend/skeleton/websocket/protocol/StrictWebSocketJsonCodec.java' 'modules/websocket/websocket-protocol/src/main/java/io/backend/skeleton/websocket/protocol/WebSocketWireTypeManifest.java' + git commit -m "feat: strict-json-codec-wire-type-manifest" + ``` + +### Task 9: Frame·Message·Connection Resource Budget + + **Files:** + - Create: `modules/websocket/websocket-core-api/src/main/java/io/backend/skeleton/websocket/core/WebSocketResourceBudget.java` +- Create: `modules/websocket/websocket-core-api/src/main/java/io/backend/skeleton/websocket/core/WebSocketGlobalBufferBudget.java` +- Create: `modules/websocket/websocket-core-api/src/main/java/io/backend/skeleton/websocket/core/WebSocketConnectionBudget.java` +- Test: `modules/websocket/websocket-core-api/src/test/java/io/backend/skeleton/websocket/core/WebSocketResourceBudgetTest.java` + + **Interfaces:** + - Consumes: Endpoint profile과 JSON manifest. + - Produces: frame·assembled message·decoded structure·queue를 각각 제한하는 budget. + + **Implementation requirements:** + - Stable 초기 text limit은 64KiB다. +- Queue는 count와 bytes를 모두 제한한다. +- 0·negative·unbounded 값은 startup에서 거부한다. +- global buffered bytes hard limit를 별도로 둔다. + + - [ ] **Step 1: Write the failing test** + + ```java + package io.backend.skeleton.websocket.core; + +class WebSocketResourceBudgetTest { + @org.junit.jupiter.api.Test + void recordShapeIsStable() { + var names = java.util.Arrays.stream(WebSocketResourceBudget.class.getRecordComponents()) + .map(java.lang.reflect.RecordComponent::getName) + .toList(); + org.assertj.core.api.Assertions.assertThat(names) + .containsExactly("maxTextMessageBytes", "maxInFlightRequests", "maxOutboundQueueMessages", "maxOutboundQueueBytes", "sendTimeLimit"); + } +} + ``` + + - [ ] **Step 2: Run the focused test and verify the failure** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-core-api:test --tests 'io.backend.skeleton.websocket.core.WebSocketResourceBudgetTest' + ``` + + Expected: FAIL because `WebSocketResourceBudget` and its required policy contract do not exist yet. + + - [ ] **Step 3: Implement the smallest complete production contract** + + ```java + package io.backend.skeleton.websocket.core; + +public record WebSocketResourceBudget( + int maxTextMessageBytes, + int maxInFlightRequests, + int maxOutboundQueueMessages, + long maxOutboundQueueBytes, + java.time.Duration sendTimeLimit) { + public WebSocketResourceBudget { + java.util.Objects.requireNonNull(sendTimeLimit, "sendTimeLimit"); + } +} + ``` + + Implement every listed production file with the exact public names, boundaries and invariants above. Keep raw transport sessions, credentials, payloads, database types and dynamic identifiers out of the public contract unless this task explicitly defines a bounded adapter type. + + - [ ] **Step 4: Run the focused test and the owning suite** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-core-api:test --tests 'io.backend.skeleton.websocket.core.WebSocketResourceBudgetTest' + ./gradlew websocketStableTest + ``` + + Expected: PASS for the focused test and the aggregate Stable suite. + + - [ ] **Step 5: Commit the independently reviewable change** + + ```bash + git add 'modules/websocket/websocket-core-api/src/main/java/io/backend/skeleton/websocket/core/WebSocketResourceBudget.java' 'modules/websocket/websocket-core-api/src/test/java/io/backend/skeleton/websocket/core/WebSocketResourceBudgetTest.java' 'modules/websocket/websocket-core-api/src/main/java/io/backend/skeleton/websocket/core/WebSocketGlobalBufferBudget.java' 'modules/websocket/websocket-core-api/src/main/java/io/backend/skeleton/websocket/core/WebSocketConnectionBudget.java' + git commit -m "feat: frame-message-connection-resource-budget" + ``` + +### Task 10: Inbound·Outbound·Connection 실행 증거 + + **Files:** + - Create: `modules/websocket/websocket-core-api/src/main/java/io/backend/skeleton/websocket/evidence/WebSocketInboundEvidence.java` +- Create: `modules/websocket/websocket-core-api/src/main/java/io/backend/skeleton/websocket/evidence/WebSocketOutboundEvidence.java` +- Create: `modules/websocket/websocket-core-api/src/main/java/io/backend/skeleton/websocket/evidence/WebSocketConnectionEvidence.java` +- Create: `modules/websocket/websocket-core-api/src/main/java/io/backend/skeleton/websocket/evidence/WebSocketExecutionEvidence.java` +- Test: `modules/websocket/websocket-core-api/src/test/java/io/backend/skeleton/websocket/evidence/WebSocketInboundEvidenceTest.java` + + **Interfaces:** + - Consumes: Connection context와 message identifiers. + - Produces: 서로 승격 불가능한 세 축 evidence model. + + **Implementation requirements:** + - WRITTEN_LOCALLY를 CLIENT_ACKED로 승격하지 않는다. +- APPLICATION_STARTED를 APPLICATION_COMMITTED로 추정하지 않는다. +- evidence transition은 monotonic하고 source가 명시돼야 한다. + + - [ ] **Step 1: Write the failing test** + + ```java + package io.backend.skeleton.websocket.evidence; + +class WebSocketInboundEvidenceTest { + @org.junit.jupiter.api.Test + void valuesAreStable() { + org.assertj.core.api.Assertions.assertThat(WebSocketInboundEvidence.values()) + .extracting(java.lang.Enum::name) + .containsExactly("FRAME_RECEIVED", "MESSAGE_ASSEMBLED", "MESSAGE_VALIDATED", "MESSAGE_AUTHORIZED", "APPLICATION_STARTED", "APPLICATION_COMMITTED", "APPLICATION_FAILED"); + } +} + ``` + + - [ ] **Step 2: Run the focused test and verify the failure** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-core-api:test --tests 'io.backend.skeleton.websocket.evidence.WebSocketInboundEvidenceTest' + ``` + + Expected: FAIL because `WebSocketInboundEvidence` and its required policy contract do not exist yet. + + - [ ] **Step 3: Implement the smallest complete production contract** + + ```java + package io.backend.skeleton.websocket.evidence; + +public enum WebSocketInboundEvidence { + FRAME_RECEIVED, + MESSAGE_ASSEMBLED, + MESSAGE_VALIDATED, + MESSAGE_AUTHORIZED, + APPLICATION_STARTED, + APPLICATION_COMMITTED, + APPLICATION_FAILED +} + ``` + + Implement every listed production file with the exact public names, boundaries and invariants above. Keep raw transport sessions, credentials, payloads, database types and dynamic identifiers out of the public contract unless this task explicitly defines a bounded adapter type. + + - [ ] **Step 4: Run the focused test and the owning suite** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-core-api:test --tests 'io.backend.skeleton.websocket.evidence.WebSocketInboundEvidenceTest' + ./gradlew websocketStableTest + ``` + + Expected: PASS for the focused test and the aggregate Stable suite. + + - [ ] **Step 5: Commit the independently reviewable change** + + ```bash + git add 'modules/websocket/websocket-core-api/src/main/java/io/backend/skeleton/websocket/evidence/WebSocketInboundEvidence.java' 'modules/websocket/websocket-core-api/src/test/java/io/backend/skeleton/websocket/evidence/WebSocketInboundEvidenceTest.java' 'modules/websocket/websocket-core-api/src/main/java/io/backend/skeleton/websocket/evidence/WebSocketOutboundEvidence.java' 'modules/websocket/websocket-core-api/src/main/java/io/backend/skeleton/websocket/evidence/WebSocketConnectionEvidence.java' 'modules/websocket/websocket-core-api/src/main/java/io/backend/skeleton/websocket/evidence/WebSocketExecutionEvidence.java' + git commit -m "feat: inbound-outbound-connection" + ``` + +### Task 11: Typed Error와 Close Code Catalog + + **Files:** + - Create: `modules/websocket/websocket-core-api/src/main/java/io/backend/skeleton/websocket/error/WebSocketCloseCode.java` +- Create: `modules/websocket/websocket-core-api/src/main/java/io/backend/skeleton/websocket/error/WebSocketErrorMessage.java` +- Create: `modules/websocket/websocket-core-api/src/main/java/io/backend/skeleton/websocket/error/WebSocketFailureCategory.java` +- Create: `modules/websocket/websocket-core-api/src/main/java/io/backend/skeleton/websocket/error/WebSocketClosePolicy.java` +- Test: `modules/websocket/websocket-core-api/src/test/java/io/backend/skeleton/websocket/error/WebSocketCloseCodeTest.java` + + **Interfaces:** + - Consumes: Execution evidence와 message catalog. + - Produces: 안정 error code·safe detail·close mapping. + + **Implementation requirements:** + - Close reason에 stack trace·SQL·token·PII를 포함하지 않는다. +- Handshake 전 오류는 HTTP Problem, 101 이후는 typed error/close다. +- Message too big은 1009, temporary overload는 1013/4503이다. + + - [ ] **Step 1: Write the failing test** + + ```java + package io.backend.skeleton.websocket.error; + +class WebSocketCloseCodeTest { + @org.junit.jupiter.api.Test + void valuesAreStable() { + org.assertj.core.api.Assertions.assertThat(WebSocketCloseCode.values()) + .extracting(java.lang.Enum::name) + .containsExactly("NORMAL_1000", "PROTOCOL_1002", "UNSUPPORTED_1003", "INVALID_PAYLOAD_1007", "POLICY_1008", "TOO_BIG_1009", "INTERNAL_1011", "RESTART_1012", "OVERLOAD_1013", "AUTH_4401", "DENIED_4403", "HEARTBEAT_4408", "DUPLICATE_4409", "VALIDATION_4422", "RATE_LIMIT_4429", "OVERLOADED_4503"); + } +} + ``` + + - [ ] **Step 2: Run the focused test and verify the failure** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-core-api:test --tests 'io.backend.skeleton.websocket.error.WebSocketCloseCodeTest' + ``` + + Expected: FAIL because `WebSocketCloseCode` and its required policy contract do not exist yet. + + - [ ] **Step 3: Implement the smallest complete production contract** + + ```java + package io.backend.skeleton.websocket.error; + +public enum WebSocketCloseCode { + NORMAL_1000, + PROTOCOL_1002, + UNSUPPORTED_1003, + INVALID_PAYLOAD_1007, + POLICY_1008, + TOO_BIG_1009, + INTERNAL_1011, + RESTART_1012, + OVERLOAD_1013, + AUTH_4401, + DENIED_4403, + HEARTBEAT_4408, + DUPLICATE_4409, + VALIDATION_4422, + RATE_LIMIT_4429, + OVERLOADED_4503 +} + ``` + + Implement every listed production file with the exact public names, boundaries and invariants above. Keep raw transport sessions, credentials, payloads, database types and dynamic identifiers out of the public contract unless this task explicitly defines a bounded adapter type. + + - [ ] **Step 4: Run the focused test and the owning suite** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-core-api:test --tests 'io.backend.skeleton.websocket.error.WebSocketCloseCodeTest' + ./gradlew websocketStableTest + ``` + + Expected: PASS for the focused test and the aggregate Stable suite. + + - [ ] **Step 5: Commit the independently reviewable change** + + ```bash + git add 'modules/websocket/websocket-core-api/src/main/java/io/backend/skeleton/websocket/error/WebSocketCloseCode.java' 'modules/websocket/websocket-core-api/src/test/java/io/backend/skeleton/websocket/error/WebSocketCloseCodeTest.java' 'modules/websocket/websocket-core-api/src/main/java/io/backend/skeleton/websocket/error/WebSocketErrorMessage.java' 'modules/websocket/websocket-core-api/src/main/java/io/backend/skeleton/websocket/error/WebSocketFailureCategory.java' 'modules/websocket/websocket-core-api/src/main/java/io/backend/skeleton/websocket/error/WebSocketClosePolicy.java' + git commit -m "feat: typed-error-close-code-catalog" + ``` + +### Task 12: Application Message Handler 경계 + + **Files:** + - Create: `modules/websocket/websocket-core-api/src/main/java/io/backend/skeleton/websocket/application/WebSocketApplicationHandler.java` +- Create: `modules/websocket/websocket-core-api/src/main/java/io/backend/skeleton/websocket/application/WebSocketApplicationRequest.java` +- Create: `modules/websocket/websocket-core-api/src/main/java/io/backend/skeleton/websocket/application/WebSocketApplicationResult.java` +- Test: `modules/websocket/websocket-core-api/src/test/java/io/backend/skeleton/websocket/application/WebSocketApplicationHandlerTest.java` + + **Interfaces:** + - Consumes: Validated typed message와 authenticated connection context. + - Produces: transport-neutral Application Use Case adapter contract. + + **Implementation requirements:** + - Handler는 Repository·MongoTemplate·raw HTTP client·broker ACK를 직접 소유하지 않는다. +- Application Commit evidence는 result ledger 또는 use case result에서만 수신한다. +- transport session type을 method signature에 노출하지 않는다. + + - [ ] **Step 1: Write the failing test** + + ```java + package io.backend.skeleton.websocket.application; + +class WebSocketApplicationHandlerTest { + @org.junit.jupiter.api.Test + void methodNamesAreStable() { + var names = java.util.Arrays.stream(WebSocketApplicationHandler.class.getDeclaredMethods()) + .map(java.lang.reflect.Method::getName) + .toList(); + org.assertj.core.api.Assertions.assertThat(names) + .contains("handle"); + } +} + ``` + + - [ ] **Step 2: Run the focused test and verify the failure** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-core-api:test --tests 'io.backend.skeleton.websocket.application.WebSocketApplicationHandlerTest' + ``` + + Expected: FAIL because `WebSocketApplicationHandler` and its required policy contract do not exist yet. + + - [ ] **Step 3: Implement the smallest complete production contract** + + ```java + package io.backend.skeleton.websocket.application; + +public interface WebSocketApplicationHandler { + java.util.concurrent.CompletionStage handle(WebSocketApplicationRequest request); +} + ``` + + Implement every listed production file with the exact public names, boundaries and invariants above. Keep raw transport sessions, credentials, payloads, database types and dynamic identifiers out of the public contract unless this task explicitly defines a bounded adapter type. + + - [ ] **Step 4: Run the focused test and the owning suite** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-core-api:test --tests 'io.backend.skeleton.websocket.application.WebSocketApplicationHandlerTest' + ./gradlew websocketStableTest + ``` + + Expected: PASS for the focused test and the aggregate Stable suite. + + - [ ] **Step 5: Commit the independently reviewable change** + + ```bash + git add 'modules/websocket/websocket-core-api/src/main/java/io/backend/skeleton/websocket/application/WebSocketApplicationHandler.java' 'modules/websocket/websocket-core-api/src/test/java/io/backend/skeleton/websocket/application/WebSocketApplicationHandlerTest.java' 'modules/websocket/websocket-core-api/src/main/java/io/backend/skeleton/websocket/application/WebSocketApplicationRequest.java' 'modules/websocket/websocket-core-api/src/main/java/io/backend/skeleton/websocket/application/WebSocketApplicationResult.java' + git commit -m "feat: application-message-handler" + ``` + +### Task 13: Request–Response Correlation Registry + + **Files:** + - Create: `modules/websocket/websocket-session/src/main/java/io/backend/skeleton/websocket/session/WebSocketPendingRequest.java` +- Create: `modules/websocket/websocket-session/src/main/java/io/backend/skeleton/websocket/session/WebSocketPendingRequestRegistry.java` +- Create: `modules/websocket/websocket-session/src/main/java/io/backend/skeleton/websocket/session/WebSocketLateResponseTombstone.java` +- Test: `modules/websocket/websocket-session/src/test/java/io/backend/skeleton/websocket/session/WebSocketPendingRequestTest.java` + + **Interfaces:** + - Consumes: Message envelope와 connection budget. + - Produces: connection-scoped bounded pending request registry와 timeout/cancel semantics. + + **Implementation requirements:** + - pending count는 endpoint maxInFlightRequests를 넘지 않는다. +- correlationId 중복은 거부한다. +- late response는 tombstone window에서 분류하고 새 request와 연결하지 않는다. + + - [ ] **Step 1: Write the failing test** + + ```java + package io.backend.skeleton.websocket.session; + +class WebSocketPendingRequestTest { + @org.junit.jupiter.api.Test + void recordShapeIsStable() { + var names = java.util.Arrays.stream(WebSocketPendingRequest.class.getRecordComponents()) + .map(java.lang.reflect.RecordComponent::getName) + .toList(); + org.assertj.core.api.Assertions.assertThat(names) + .containsExactly("messageId", "correlationId", "deadline", "state"); + } +} + ``` + + - [ ] **Step 2: Run the focused test and verify the failure** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-session:test --tests 'io.backend.skeleton.websocket.session.WebSocketPendingRequestTest' + ``` + + Expected: FAIL because `WebSocketPendingRequest` and its required policy contract do not exist yet. + + - [ ] **Step 3: Implement the smallest complete production contract** + + ```java + package io.backend.skeleton.websocket.session; + +public record WebSocketPendingRequest( + String messageId, + String correlationId, + java.time.Instant deadline, + String state) { + public WebSocketPendingRequest { + java.util.Objects.requireNonNull(messageId, "messageId"); + java.util.Objects.requireNonNull(correlationId, "correlationId"); + java.util.Objects.requireNonNull(deadline, "deadline"); + java.util.Objects.requireNonNull(state, "state"); + } +} + ``` + + Implement every listed production file with the exact public names, boundaries and invariants above. Keep raw transport sessions, credentials, payloads, database types and dynamic identifiers out of the public contract unless this task explicitly defines a bounded adapter type. + + - [ ] **Step 4: Run the focused test and the owning suite** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-session:test --tests 'io.backend.skeleton.websocket.session.WebSocketPendingRequestTest' + ./gradlew websocketStableTest + ``` + + Expected: PASS for the focused test and the aggregate Stable suite. + + - [ ] **Step 5: Commit the independently reviewable change** + + ```bash + git add 'modules/websocket/websocket-session/src/main/java/io/backend/skeleton/websocket/session/WebSocketPendingRequest.java' 'modules/websocket/websocket-session/src/test/java/io/backend/skeleton/websocket/session/WebSocketPendingRequestTest.java' 'modules/websocket/websocket-session/src/main/java/io/backend/skeleton/websocket/session/WebSocketPendingRequestRegistry.java' 'modules/websocket/websocket-session/src/main/java/io/backend/skeleton/websocket/session/WebSocketLateResponseTombstone.java' + git commit -m "feat: request-response-correlation-registry" + ``` + +### Task 14: Command Idempotency Context + + **Files:** + - Create: `modules/websocket/websocket-resilience/src/main/java/io/backend/skeleton/websocket/resilience/WebSocketCommandIdempotencyContext.java` +- Create: `modules/websocket/websocket-resilience/src/main/java/io/backend/skeleton/websocket/resilience/WebSocketCommandId.java` +- Create: `modules/websocket/websocket-resilience/src/main/java/io/backend/skeleton/websocket/resilience/WebSocketCommandFingerprint.java` +- Test: `modules/websocket/websocket-resilience/src/test/java/io/backend/skeleton/websocket/resilience/WebSocketCommandIdempotencyContextTest.java` + + **Interfaces:** + - Consumes: COMMAND envelope와 authenticated actor·tenant context. + - Produces: connection sequence와 분리된 durable command identity. + + **Implementation requirements:** + - scope는 actor·tenant·message type을 포함한다. +- 같은 key에 다른 fingerprint를 허용하지 않는다. +- expiresAt 없는 offline-capable command를 허용하지 않는다. + + - [ ] **Step 1: Write the failing test** + + ```java + package io.backend.skeleton.websocket.resilience; + +class WebSocketCommandIdempotencyContextTest { + @org.junit.jupiter.api.Test + void recordShapeIsStable() { + var names = java.util.Arrays.stream(WebSocketCommandIdempotencyContext.class.getRecordComponents()) + .map(java.lang.reflect.RecordComponent::getName) + .toList(); + org.assertj.core.api.Assertions.assertThat(names) + .containsExactly("scope", "idempotencyKey", "fingerprint", "expiresAt"); + } +} + ``` + + - [ ] **Step 2: Run the focused test and verify the failure** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-resilience:test --tests 'io.backend.skeleton.websocket.resilience.WebSocketCommandIdempotencyContextTest' + ``` + + Expected: FAIL because `WebSocketCommandIdempotencyContext` and its required policy contract do not exist yet. + + - [ ] **Step 3: Implement the smallest complete production contract** + + ```java + package io.backend.skeleton.websocket.resilience; + +public record WebSocketCommandIdempotencyContext( + String scope, + String idempotencyKey, + String fingerprint, + java.time.Instant expiresAt) { + public WebSocketCommandIdempotencyContext { + java.util.Objects.requireNonNull(scope, "scope"); + java.util.Objects.requireNonNull(idempotencyKey, "idempotencyKey"); + java.util.Objects.requireNonNull(fingerprint, "fingerprint"); + java.util.Objects.requireNonNull(expiresAt, "expiresAt"); + } +} + ``` + + Implement every listed production file with the exact public names, boundaries and invariants above. Keep raw transport sessions, credentials, payloads, database types and dynamic identifiers out of the public contract unless this task explicitly defines a bounded adapter type. + + - [ ] **Step 4: Run the focused test and the owning suite** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-resilience:test --tests 'io.backend.skeleton.websocket.resilience.WebSocketCommandIdempotencyContextTest' + ./gradlew websocketStableTest + ``` + + Expected: PASS for the focused test and the aggregate Stable suite. + + - [ ] **Step 5: Commit the independently reviewable change** + + ```bash + git add 'modules/websocket/websocket-resilience/src/main/java/io/backend/skeleton/websocket/resilience/WebSocketCommandIdempotencyContext.java' 'modules/websocket/websocket-resilience/src/test/java/io/backend/skeleton/websocket/resilience/WebSocketCommandIdempotencyContextTest.java' 'modules/websocket/websocket-resilience/src/main/java/io/backend/skeleton/websocket/resilience/WebSocketCommandId.java' 'modules/websocket/websocket-resilience/src/main/java/io/backend/skeleton/websocket/resilience/WebSocketCommandFingerprint.java' + git commit -m "feat: command-idempotency-context" + ``` + +### Task 15: Committed Result Ledger SPI와 Reconciliation + + **Files:** + - Create: `modules/websocket/websocket-resilience/src/main/java/io/backend/skeleton/websocket/resilience/WebSocketCommandResultLedger.java` +- Create: `modules/websocket/websocket-resilience/src/main/java/io/backend/skeleton/websocket/resilience/WebSocketCommandLedgerEntry.java` +- Create: `modules/websocket/websocket-resilience/src/main/java/io/backend/skeleton/websocket/resilience/WebSocketCompletionReconciler.java` +- Create: `modules/websocket/websocket-resilience/src/main/java/io/backend/skeleton/websocket/resilience/WebSocketCompletionState.java` +- Test: `modules/websocket/websocket-resilience/src/test/java/io/backend/skeleton/websocket/resilience/WebSocketCommandResultLedgerTest.java` + + **Interfaces:** + - Consumes: Task 14 command identity와 Application commit evidence. + - Produces: ABSENT·PROCESSING·COMMITTED·FAILED_TERMINAL·EXPIRED ledger and replay/reconcile contract. + + **Implementation requirements:** + - COMMITTED 결과는 새 업무 실행 없이 replay한다. +- PROCESSING은 second execution을 차단한다. +- commit unknown은 자동 재실행하지 않는다. +- JPA 구현은 business mutation과 ledger commit을 같은 transaction에 둘 수 있어야 한다. + + - [ ] **Step 1: Write the failing test** + + ```java + package io.backend.skeleton.websocket.resilience; + +class WebSocketCommandResultLedgerTest { + @org.junit.jupiter.api.Test + void methodNamesAreStable() { + var names = java.util.Arrays.stream(WebSocketCommandResultLedger.class.getDeclaredMethods()) + .map(java.lang.reflect.Method::getName) + .toList(); + org.assertj.core.api.Assertions.assertThat(names) + .contains("find", "begin", "committed"); + } +} + ``` + + - [ ] **Step 2: Run the focused test and verify the failure** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-resilience:test --tests 'io.backend.skeleton.websocket.resilience.WebSocketCommandResultLedgerTest' + ``` + + Expected: FAIL because `WebSocketCommandResultLedger` and its required policy contract do not exist yet. + + - [ ] **Step 3: Implement the smallest complete production contract** + + ```java + package io.backend.skeleton.websocket.resilience; + +public interface WebSocketCommandResultLedger { + java.util.concurrent.CompletionStage find(String scope, String idempotencyKey); + + java.util.concurrent.CompletionStage begin(WebSocketCommandIdempotencyContext context); + + java.util.concurrent.CompletionStage committed(String scope, String idempotencyKey, String resultReference); +} + ``` + + Implement every listed production file with the exact public names, boundaries and invariants above. Keep raw transport sessions, credentials, payloads, database types and dynamic identifiers out of the public contract unless this task explicitly defines a bounded adapter type. + + - [ ] **Step 4: Run the focused test and the owning suite** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-resilience:test --tests 'io.backend.skeleton.websocket.resilience.WebSocketCommandResultLedgerTest' + ./gradlew websocketStableTest + ``` + + Expected: PASS for the focused test and the aggregate Stable suite. + + - [ ] **Step 5: Commit the independently reviewable change** + + ```bash + git add 'modules/websocket/websocket-resilience/src/main/java/io/backend/skeleton/websocket/resilience/WebSocketCommandResultLedger.java' 'modules/websocket/websocket-resilience/src/test/java/io/backend/skeleton/websocket/resilience/WebSocketCommandResultLedgerTest.java' 'modules/websocket/websocket-resilience/src/main/java/io/backend/skeleton/websocket/resilience/WebSocketCommandLedgerEntry.java' 'modules/websocket/websocket-resilience/src/main/java/io/backend/skeleton/websocket/resilience/WebSocketCompletionReconciler.java' 'modules/websocket/websocket-resilience/src/main/java/io/backend/skeleton/websocket/resilience/WebSocketCompletionState.java' + git commit -m "feat: committed-result-ledger-spi-reconciliation" + ``` + +### Task 16: Authentication Profile + + **Files:** + - Create: `modules/websocket/websocket-security/src/main/java/io/backend/skeleton/websocket/security/WebSocketAuthenticationProfile.java` +- Create: `modules/websocket/websocket-security/src/main/java/io/backend/skeleton/websocket/security/WebSocketAuthenticatedPrincipal.java` +- Create: `modules/websocket/websocket-security/src/main/java/io/backend/skeleton/websocket/security/WebSocketCredentialPolicy.java` +- Test: `modules/websocket/websocket-security/src/test/java/io/backend/skeleton/websocket/security/WebSocketAuthenticationProfileTest.java` + + **Interfaces:** + - Consumes: Security module의 검증된 actor·tenant source. + - Produces: Stable browser authentication profile과 credential lifetime policy. + + **Implementation requirements:** + - long-lived query bearer를 제공하지 않는다. +- credential 만료 또는 권한 회수 시 connection을 종료한다. +- client payload actor·tenant를 인증 원천으로 사용하지 않는다. + + - [ ] **Step 1: Write the failing test** + + ```java + package io.backend.skeleton.websocket.security; + +class WebSocketAuthenticationProfileTest { + @org.junit.jupiter.api.Test + void valuesAreStable() { + org.assertj.core.api.Assertions.assertThat(WebSocketAuthenticationProfile.values()) + .extracting(java.lang.Enum::name) + .containsExactly("HTTP_SESSION", "ONE_TIME_TICKET"); + } +} + ``` + + - [ ] **Step 2: Run the focused test and verify the failure** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-security:test --tests 'io.backend.skeleton.websocket.security.WebSocketAuthenticationProfileTest' + ``` + + Expected: FAIL because `WebSocketAuthenticationProfile` and its required policy contract do not exist yet. + + - [ ] **Step 3: Implement the smallest complete production contract** + + ```java + package io.backend.skeleton.websocket.security; + +public enum WebSocketAuthenticationProfile { + HTTP_SESSION, + ONE_TIME_TICKET +} + ``` + + Implement every listed production file with the exact public names, boundaries and invariants above. Keep raw transport sessions, credentials, payloads, database types and dynamic identifiers out of the public contract unless this task explicitly defines a bounded adapter type. + + - [ ] **Step 4: Run the focused test and the owning suite** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-security:test --tests 'io.backend.skeleton.websocket.security.WebSocketAuthenticationProfileTest' + ./gradlew websocketStableTest + ``` + + Expected: PASS for the focused test and the aggregate Stable suite. + + - [ ] **Step 5: Commit the independently reviewable change** + + ```bash + git add 'modules/websocket/websocket-security/src/main/java/io/backend/skeleton/websocket/security/WebSocketAuthenticationProfile.java' 'modules/websocket/websocket-security/src/test/java/io/backend/skeleton/websocket/security/WebSocketAuthenticationProfileTest.java' 'modules/websocket/websocket-security/src/main/java/io/backend/skeleton/websocket/security/WebSocketAuthenticatedPrincipal.java' 'modules/websocket/websocket-security/src/main/java/io/backend/skeleton/websocket/security/WebSocketCredentialPolicy.java' + git commit -m "feat: authentication-profile" + ``` + +### Task 17: Origin·CSRF 정책 + + **Files:** + - Create: `modules/websocket/websocket-security/src/main/java/io/backend/skeleton/websocket/security/WebSocketOriginPolicy.java` +- Create: `modules/websocket/websocket-security/src/main/java/io/backend/skeleton/websocket/security/WebSocketOriginMatcher.java` +- Create: `modules/websocket/websocket-security/src/main/java/io/backend/skeleton/websocket/security/WebSocketCsrfPolicy.java` +- Test: `modules/websocket/websocket-security/src/test/java/io/backend/skeleton/websocket/security/WebSocketOriginPolicyTest.java` + + **Interfaces:** + - Consumes: Endpoint profile과 authentication profile. + - Produces: exact Origin allowlist와 Cookie/STOMP CSRF requirement. + + **Implementation requirements:** + - credentialsAllowed=true일 때 wildcard Origin을 거부한다. +- null Origin은 기본 false다. +- Origin 원문을 metric tag로 기록하지 않는다. + + - [ ] **Step 1: Write the failing test** + + ```java + package io.backend.skeleton.websocket.security; + +class WebSocketOriginPolicyTest { + @org.junit.jupiter.api.Test + void recordShapeIsStable() { + var names = java.util.Arrays.stream(WebSocketOriginPolicy.class.getRecordComponents()) + .map(java.lang.reflect.RecordComponent::getName) + .toList(); + org.assertj.core.api.Assertions.assertThat(names) + .containsExactly("allowedOrigins", "allowNullOrigin", "credentialsAllowed"); + } +} + ``` + + - [ ] **Step 2: Run the focused test and verify the failure** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-security:test --tests 'io.backend.skeleton.websocket.security.WebSocketOriginPolicyTest' + ``` + + Expected: FAIL because `WebSocketOriginPolicy` and its required policy contract do not exist yet. + + - [ ] **Step 3: Implement the smallest complete production contract** + + ```java + package io.backend.skeleton.websocket.security; + +public record WebSocketOriginPolicy( + java.util.Set allowedOrigins, + boolean allowNullOrigin, + boolean credentialsAllowed) { + public WebSocketOriginPolicy { + java.util.Objects.requireNonNull(allowedOrigins, "allowedOrigins"); + } +} + ``` + + Implement every listed production file with the exact public names, boundaries and invariants above. Keep raw transport sessions, credentials, payloads, database types and dynamic identifiers out of the public contract unless this task explicitly defines a bounded adapter type. + + - [ ] **Step 4: Run the focused test and the owning suite** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-security:test --tests 'io.backend.skeleton.websocket.security.WebSocketOriginPolicyTest' + ./gradlew websocketStableTest + ``` + + Expected: PASS for the focused test and the aggregate Stable suite. + + - [ ] **Step 5: Commit the independently reviewable change** + + ```bash + git add 'modules/websocket/websocket-security/src/main/java/io/backend/skeleton/websocket/security/WebSocketOriginPolicy.java' 'modules/websocket/websocket-security/src/test/java/io/backend/skeleton/websocket/security/WebSocketOriginPolicyTest.java' 'modules/websocket/websocket-security/src/main/java/io/backend/skeleton/websocket/security/WebSocketOriginMatcher.java' 'modules/websocket/websocket-security/src/main/java/io/backend/skeleton/websocket/security/WebSocketCsrfPolicy.java' + git commit -m "feat: origin-csrf" + ``` + +### Task 18: One-time Connection Ticket + + **Files:** + - Create: `modules/websocket/websocket-security/src/main/java/io/backend/skeleton/websocket/security/WebSocketConnectionTicket.java` +- Create: `modules/websocket/websocket-security/src/main/java/io/backend/skeleton/websocket/security/WebSocketTicketIssuer.java` +- Create: `modules/websocket/websocket-security/src/main/java/io/backend/skeleton/websocket/security/WebSocketTicketStore.java` +- Create: `modules/websocket/websocket-security/src/main/java/io/backend/skeleton/websocket/security/WebSocketTicketConsumer.java` +- Test: `modules/websocket/websocket-security/src/test/java/io/backend/skeleton/websocket/security/WebSocketConnectionTicketTest.java` + + **Interfaces:** + - Consumes: Redis atomic consume capability 또는 equivalent store. + - Produces: 짧은 TTL·one-time·actor/tenant/endpoint/origin-bound ticket contract. + + **Implementation requirements:** + - ticket 전체 값을 access log에 남기지 않는다. +- consume은 원자적이어야 한다. +- 재사용·만료·origin mismatch를 구분한다. + + - [ ] **Step 1: Write the failing test** + + ```java + package io.backend.skeleton.websocket.security; + +class WebSocketConnectionTicketTest { + @org.junit.jupiter.api.Test + void recordShapeIsStable() { + var names = java.util.Arrays.stream(WebSocketConnectionTicket.class.getRecordComponents()) + .map(java.lang.reflect.RecordComponent::getName) + .toList(); + org.assertj.core.api.Assertions.assertThat(names) + .containsExactly("ticketId", "actorFingerprint", "tenantFingerprint", "endpointName", "origin", "expiresAt"); + } +} + ``` + + - [ ] **Step 2: Run the focused test and verify the failure** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-security:test --tests 'io.backend.skeleton.websocket.security.WebSocketConnectionTicketTest' + ``` + + Expected: FAIL because `WebSocketConnectionTicket` and its required policy contract do not exist yet. + + - [ ] **Step 3: Implement the smallest complete production contract** + + ```java + package io.backend.skeleton.websocket.security; + +public record WebSocketConnectionTicket( + String ticketId, + String actorFingerprint, + String tenantFingerprint, + String endpointName, + String origin, + java.time.Instant expiresAt) { + public WebSocketConnectionTicket { + java.util.Objects.requireNonNull(ticketId, "ticketId"); + java.util.Objects.requireNonNull(actorFingerprint, "actorFingerprint"); + java.util.Objects.requireNonNull(tenantFingerprint, "tenantFingerprint"); + java.util.Objects.requireNonNull(endpointName, "endpointName"); + java.util.Objects.requireNonNull(origin, "origin"); + java.util.Objects.requireNonNull(expiresAt, "expiresAt"); + } +} + ``` + + Implement every listed production file with the exact public names, boundaries and invariants above. Keep raw transport sessions, credentials, payloads, database types and dynamic identifiers out of the public contract unless this task explicitly defines a bounded adapter type. + + - [ ] **Step 4: Run the focused test and the owning suite** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-security:test --tests 'io.backend.skeleton.websocket.security.WebSocketConnectionTicketTest' + ./gradlew websocketStableTest + ``` + + Expected: PASS for the focused test and the aggregate Stable suite. + + - [ ] **Step 5: Commit the independently reviewable change** + + ```bash + git add 'modules/websocket/websocket-security/src/main/java/io/backend/skeleton/websocket/security/WebSocketConnectionTicket.java' 'modules/websocket/websocket-security/src/test/java/io/backend/skeleton/websocket/security/WebSocketConnectionTicketTest.java' 'modules/websocket/websocket-security/src/main/java/io/backend/skeleton/websocket/security/WebSocketTicketIssuer.java' 'modules/websocket/websocket-security/src/main/java/io/backend/skeleton/websocket/security/WebSocketTicketStore.java' 'modules/websocket/websocket-security/src/main/java/io/backend/skeleton/websocket/security/WebSocketTicketConsumer.java' + git commit -m "feat: one-time-connection-ticket" + ``` + +### Task 19: Handshake Admission Pipeline + + **Files:** + - Create: `modules/websocket/websocket-security/src/main/java/io/backend/skeleton/websocket/security/WebSocketHandshakeDecision.java` +- Create: `modules/websocket/websocket-security/src/main/java/io/backend/skeleton/websocket/security/WebSocketHandshakePipeline.java` +- Create: `modules/websocket/websocket-security/src/main/java/io/backend/skeleton/websocket/security/WebSocketConnectionAdmission.java` +- Test: `modules/websocket/websocket-security/src/test/java/io/backend/skeleton/websocket/security/WebSocketHandshakeDecisionTest.java` + + **Interfaces:** + - Consumes: Endpoint·Origin·Authentication·Ticket·Connection budget. + - Produces: 400·401·403·404·409·429·503 또는 negotiated 101 decision. + + **Implementation requirements:** + - 검증 순서를 설계서와 동일하게 고정한다. +- admission 통과 전 transport session을 registry에 등록하지 않는다. +- unsupported subprotocol은 typed endpoint에서 거부한다. + + - [ ] **Step 1: Write the failing test** + + ```java + package io.backend.skeleton.websocket.security; + +class WebSocketHandshakeDecisionTest { + @org.junit.jupiter.api.Test + void recordShapeIsStable() { + var names = java.util.Arrays.stream(WebSocketHandshakeDecision.class.getRecordComponents()) + .map(java.lang.reflect.RecordComponent::getName) + .toList(); + org.assertj.core.api.Assertions.assertThat(names) + .containsExactly("accepted", "httpStatus", "reasonCode", "subprotocol"); + } +} + ``` + + - [ ] **Step 2: Run the focused test and verify the failure** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-security:test --tests 'io.backend.skeleton.websocket.security.WebSocketHandshakeDecisionTest' + ``` + + Expected: FAIL because `WebSocketHandshakeDecision` and its required policy contract do not exist yet. + + - [ ] **Step 3: Implement the smallest complete production contract** + + ```java + package io.backend.skeleton.websocket.security; + +public record WebSocketHandshakeDecision( + boolean accepted, + int httpStatus, + String reasonCode, + String subprotocol) { + public WebSocketHandshakeDecision { + java.util.Objects.requireNonNull(reasonCode, "reasonCode"); + java.util.Objects.requireNonNull(subprotocol, "subprotocol"); + } +} + ``` + + Implement every listed production file with the exact public names, boundaries and invariants above. Keep raw transport sessions, credentials, payloads, database types and dynamic identifiers out of the public contract unless this task explicitly defines a bounded adapter type. + + - [ ] **Step 4: Run the focused test and the owning suite** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-security:test --tests 'io.backend.skeleton.websocket.security.WebSocketHandshakeDecisionTest' + ./gradlew websocketStableTest + ``` + + Expected: PASS for the focused test and the aggregate Stable suite. + + - [ ] **Step 5: Commit the independently reviewable change** + + ```bash + git add 'modules/websocket/websocket-security/src/main/java/io/backend/skeleton/websocket/security/WebSocketHandshakeDecision.java' 'modules/websocket/websocket-security/src/test/java/io/backend/skeleton/websocket/security/WebSocketHandshakeDecisionTest.java' 'modules/websocket/websocket-security/src/main/java/io/backend/skeleton/websocket/security/WebSocketHandshakePipeline.java' 'modules/websocket/websocket-security/src/main/java/io/backend/skeleton/websocket/security/WebSocketConnectionAdmission.java' + git commit -m "feat: handshake-admission-pipeline" + ``` + +### Task 20: Node-local Session Registry + + **Files:** + - Create: `modules/websocket/websocket-session/src/main/java/io/backend/skeleton/websocket/session/WebSocketSessionSummary.java` +- Create: `modules/websocket/websocket-session/src/main/java/io/backend/skeleton/websocket/session/WebSocketLocalSessionRegistry.java` +- Create: `modules/websocket/websocket-session/src/main/java/io/backend/skeleton/websocket/session/WebSocketSessionHandle.java` +- Test: `modules/websocket/websocket-session/src/test/java/io/backend/skeleton/websocket/session/WebSocketSessionSummaryTest.java` + + **Interfaces:** + - Consumes: Connection context와 handshake accepted event. + - Produces: actual transport handle을 node memory에만 보존하는 registry. + + **Implementation requirements:** + - Redis에 transport session을 직렬화하지 않는다. +- duplicate connectionId를 거부한다. +- close·abnormal termination에서 registry entry를 제거한다. + + - [ ] **Step 1: Write the failing test** + + ```java + package io.backend.skeleton.websocket.session; + +class WebSocketSessionSummaryTest { + @org.junit.jupiter.api.Test + void recordShapeIsStable() { + var names = java.util.Arrays.stream(WebSocketSessionSummary.class.getRecordComponents()) + .map(java.lang.reflect.RecordComponent::getName) + .toList(); + org.assertj.core.api.Assertions.assertThat(names) + .containsExactly("connectionId", "sessionId", "endpointName", "nodeId", "state", "connectedAt"); + } +} + ``` + + - [ ] **Step 2: Run the focused test and verify the failure** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-session:test --tests 'io.backend.skeleton.websocket.session.WebSocketSessionSummaryTest' + ``` + + Expected: FAIL because `WebSocketSessionSummary` and its required policy contract do not exist yet. + + - [ ] **Step 3: Implement the smallest complete production contract** + + ```java + package io.backend.skeleton.websocket.session; + +public record WebSocketSessionSummary( + String connectionId, + String sessionId, + String endpointName, + String nodeId, + String state, + java.time.Instant connectedAt) { + public WebSocketSessionSummary { + java.util.Objects.requireNonNull(connectionId, "connectionId"); + java.util.Objects.requireNonNull(sessionId, "sessionId"); + java.util.Objects.requireNonNull(endpointName, "endpointName"); + java.util.Objects.requireNonNull(nodeId, "nodeId"); + java.util.Objects.requireNonNull(state, "state"); + java.util.Objects.requireNonNull(connectedAt, "connectedAt"); + } +} + ``` + + Implement every listed production file with the exact public names, boundaries and invariants above. Keep raw transport sessions, credentials, payloads, database types and dynamic identifiers out of the public contract unless this task explicitly defines a bounded adapter type. + + - [ ] **Step 4: Run the focused test and the owning suite** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-session:test --tests 'io.backend.skeleton.websocket.session.WebSocketSessionSummaryTest' + ./gradlew websocketStableTest + ``` + + Expected: PASS for the focused test and the aggregate Stable suite. + + - [ ] **Step 5: Commit the independently reviewable change** + + ```bash + git add 'modules/websocket/websocket-session/src/main/java/io/backend/skeleton/websocket/session/WebSocketSessionSummary.java' 'modules/websocket/websocket-session/src/test/java/io/backend/skeleton/websocket/session/WebSocketSessionSummaryTest.java' 'modules/websocket/websocket-session/src/main/java/io/backend/skeleton/websocket/session/WebSocketLocalSessionRegistry.java' 'modules/websocket/websocket-session/src/main/java/io/backend/skeleton/websocket/session/WebSocketSessionHandle.java' + git commit -m "feat: node-local-session-registry" + ``` + +### Task 21: Architecture Boundary Rule + + **Files:** + - Create: `modules/websocket/websocket-testkit-core/src/main/java/io/backend/skeleton/websocket/architecture/WebSocketArchitectureRuleSet.java` +- Create: `modules/websocket/websocket-testkit-core/src/test/java/io/backend/skeleton/websocket/architecture/WebSocketArchitectureTest.java` +- Test: `modules/websocket/websocket-testkit-core/src/test/java/io/backend/skeleton/websocket/architecture/WebSocketArchitectureRuleSetTest.java` + + **Interfaces:** + - Consumes: Stable module graph와 package layout. + - Produces: raw session·Repository·MongoTemplate·SimpMessagingTemplate import 차단 규칙. + + **Implementation requirements:** + - Application adapter에서 Spring WebSocketSession import를 금지한다. +- core-api에서 Servlet·Reactor·Netty·STOMP import를 금지한다. +- Stable starter에서 advanced package dependency를 금지한다. + + - [ ] **Step 1: Write the failing test** + + ```java + package io.backend.skeleton.websocket.architecture; + +class WebSocketArchitectureRuleSetTest { + @org.junit.jupiter.api.Test + void policyNameIsStable() { + org.assertj.core.api.Assertions.assertThat(WebSocketArchitectureRuleSet.policyName()) + .isEqualTo("websocket-architecture-boundary"); + } +} + ``` + + - [ ] **Step 2: Run the focused test and verify the failure** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-testkit-core:test --tests 'io.backend.skeleton.websocket.architecture.WebSocketArchitectureRuleSetTest' + ``` + + Expected: FAIL because `WebSocketArchitectureRuleSet` and its required policy contract do not exist yet. + + - [ ] **Step 3: Implement the smallest complete production contract** + + ```java + package io.backend.skeleton.websocket.architecture; + +public final class WebSocketArchitectureRuleSet { + private WebSocketArchitectureRuleSet() {} + + public static String policyName() { + return "websocket-architecture-boundary"; + } +} + ``` + + Implement every listed production file with the exact public names, boundaries and invariants above. Keep raw transport sessions, credentials, payloads, database types and dynamic identifiers out of the public contract unless this task explicitly defines a bounded adapter type. + + - [ ] **Step 4: Run the focused test and the owning suite** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-testkit-core:test --tests 'io.backend.skeleton.websocket.architecture.WebSocketArchitectureRuleSetTest' + ./gradlew websocketStableTest + ``` + + Expected: PASS for the focused test and the aggregate Stable suite. + + - [ ] **Step 5: Commit the independently reviewable change** + + ```bash + git add 'modules/websocket/websocket-testkit-core/src/main/java/io/backend/skeleton/websocket/architecture/WebSocketArchitectureRuleSet.java' 'modules/websocket/websocket-testkit-core/src/test/java/io/backend/skeleton/websocket/architecture/WebSocketArchitectureRuleSetTest.java' 'modules/websocket/websocket-testkit-core/src/test/java/io/backend/skeleton/websocket/architecture/WebSocketArchitectureTest.java' + git commit -m "feat: architecture-boundary-rule" + ``` + +### Task 22: Inbound Fragment Assembly와 Decode + + **Files:** + - Create: `modules/websocket/websocket-protocol/src/main/java/io/backend/skeleton/websocket/protocol/WebSocketInboundAssemblyResult.java` +- Create: `modules/websocket/websocket-protocol/src/main/java/io/backend/skeleton/websocket/protocol/WebSocketInboundAssembler.java` +- Create: `modules/websocket/websocket-protocol/src/main/java/io/backend/skeleton/websocket/protocol/WebSocketInboundDecoder.java` +- Test: `modules/websocket/websocket-protocol/src/test/java/io/backend/skeleton/websocket/protocol/WebSocketInboundAssemblyResultTest.java` + + **Interfaces:** + - Consumes: Frame stream, endpoint budget, strict JSON codec. + - Produces: fragment limit·assembled size·UTF-8 validity를 검증한 typed message. + + **Implementation requirements:** + - assembled bytes를 decode 전에 제한한다. +- invalid UTF-8과 malformed JSON을 구분한다. +- 부분 message가 close 후 handler에 전달되지 않도록 한다. + + - [ ] **Step 1: Write the failing test** + + ```java + package io.backend.skeleton.websocket.protocol; + +class WebSocketInboundAssemblyResultTest { + @org.junit.jupiter.api.Test + void recordShapeIsStable() { + var names = java.util.Arrays.stream(WebSocketInboundAssemblyResult.class.getRecordComponents()) + .map(java.lang.reflect.RecordComponent::getName) + .toList(); + org.assertj.core.api.Assertions.assertThat(names) + .containsExactly("connectionId", "fragmentCount", "assembledBytes", "complete"); + } +} + ``` + + - [ ] **Step 2: Run the focused test and verify the failure** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-protocol:test --tests 'io.backend.skeleton.websocket.protocol.WebSocketInboundAssemblyResultTest' + ``` + + Expected: FAIL because `WebSocketInboundAssemblyResult` and its required policy contract do not exist yet. + + - [ ] **Step 3: Implement the smallest complete production contract** + + ```java + package io.backend.skeleton.websocket.protocol; + +public record WebSocketInboundAssemblyResult( + String connectionId, + int fragmentCount, + long assembledBytes, + boolean complete) { + public WebSocketInboundAssemblyResult { + java.util.Objects.requireNonNull(connectionId, "connectionId"); + } +} + ``` + + Implement every listed production file with the exact public names, boundaries and invariants above. Keep raw transport sessions, credentials, payloads, database types and dynamic identifiers out of the public contract unless this task explicitly defines a bounded adapter type. + + - [ ] **Step 4: Run the focused test and the owning suite** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-protocol:test --tests 'io.backend.skeleton.websocket.protocol.WebSocketInboundAssemblyResultTest' + ./gradlew websocketStableTest + ``` + + Expected: PASS for the focused test and the aggregate Stable suite. + + - [ ] **Step 5: Commit the independently reviewable change** + + ```bash + git add 'modules/websocket/websocket-protocol/src/main/java/io/backend/skeleton/websocket/protocol/WebSocketInboundAssemblyResult.java' 'modules/websocket/websocket-protocol/src/test/java/io/backend/skeleton/websocket/protocol/WebSocketInboundAssemblyResultTest.java' 'modules/websocket/websocket-protocol/src/main/java/io/backend/skeleton/websocket/protocol/WebSocketInboundAssembler.java' 'modules/websocket/websocket-protocol/src/main/java/io/backend/skeleton/websocket/protocol/WebSocketInboundDecoder.java' + git commit -m "feat: inbound-fragment-assembly-decode" + ``` + +### Task 23: Message Authorization Policy + + **Files:** + - Create: `modules/websocket/websocket-security/src/main/java/io/backend/skeleton/websocket/security/WebSocketMessageAuthorizationDecision.java` +- Create: `modules/websocket/websocket-security/src/main/java/io/backend/skeleton/websocket/security/WebSocketMessageAuthorizer.java` +- Create: `modules/websocket/websocket-security/src/main/java/io/backend/skeleton/websocket/security/WebSocketMessagePolicyCatalog.java` +- Test: `modules/websocket/websocket-security/src/test/java/io/backend/skeleton/websocket/security/WebSocketMessageAuthorizationDecisionTest.java` + + **Interfaces:** + - Consumes: Authenticated connection context와 message descriptor. + - Produces: message type·resource·tenant 기반 authorization hook. + + **Implementation requirements:** + - Connection 인증을 객체 권한으로 승격하지 않는다. +- 권한 회수 profile은 message/event 시점 재검증을 지원한다. +- 거부된 message는 Application handler에 도달하지 않는다. + + - [ ] **Step 1: Write the failing test** + + ```java + package io.backend.skeleton.websocket.security; + +class WebSocketMessageAuthorizationDecisionTest { + @org.junit.jupiter.api.Test + void recordShapeIsStable() { + var names = java.util.Arrays.stream(WebSocketMessageAuthorizationDecision.class.getRecordComponents()) + .map(java.lang.reflect.RecordComponent::getName) + .toList(); + org.assertj.core.api.Assertions.assertThat(names) + .containsExactly("allowed", "reasonCode", "policyName"); + } +} + ``` + + - [ ] **Step 2: Run the focused test and verify the failure** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-security:test --tests 'io.backend.skeleton.websocket.security.WebSocketMessageAuthorizationDecisionTest' + ``` + + Expected: FAIL because `WebSocketMessageAuthorizationDecision` and its required policy contract do not exist yet. + + - [ ] **Step 3: Implement the smallest complete production contract** + + ```java + package io.backend.skeleton.websocket.security; + +public record WebSocketMessageAuthorizationDecision( + boolean allowed, + String reasonCode, + String policyName) { + public WebSocketMessageAuthorizationDecision { + java.util.Objects.requireNonNull(reasonCode, "reasonCode"); + java.util.Objects.requireNonNull(policyName, "policyName"); + } +} + ``` + + Implement every listed production file with the exact public names, boundaries and invariants above. Keep raw transport sessions, credentials, payloads, database types and dynamic identifiers out of the public contract unless this task explicitly defines a bounded adapter type. + + - [ ] **Step 4: Run the focused test and the owning suite** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-security:test --tests 'io.backend.skeleton.websocket.security.WebSocketMessageAuthorizationDecisionTest' + ./gradlew websocketStableTest + ``` + + Expected: PASS for the focused test and the aggregate Stable suite. + + - [ ] **Step 5: Commit the independently reviewable change** + + ```bash + git add 'modules/websocket/websocket-security/src/main/java/io/backend/skeleton/websocket/security/WebSocketMessageAuthorizationDecision.java' 'modules/websocket/websocket-security/src/test/java/io/backend/skeleton/websocket/security/WebSocketMessageAuthorizationDecisionTest.java' 'modules/websocket/websocket-security/src/main/java/io/backend/skeleton/websocket/security/WebSocketMessageAuthorizer.java' 'modules/websocket/websocket-security/src/main/java/io/backend/skeleton/websocket/security/WebSocketMessagePolicyCatalog.java' + git commit -m "feat: message-authorization-policy" + ``` + +### Task 24: Outbound 전달 성격과 Priority + + **Files:** + - Create: `modules/websocket/websocket-core-api/src/main/java/io/backend/skeleton/websocket/outbound/WebSocketDeliveryClass.java` +- Create: `modules/websocket/websocket-core-api/src/main/java/io/backend/skeleton/websocket/outbound/WebSocketOutboundPriority.java` +- Create: `modules/websocket/websocket-core-api/src/main/java/io/backend/skeleton/websocket/outbound/WebSocketOutboundMessage.java` +- Test: `modules/websocket/websocket-core-api/src/test/java/io/backend/skeleton/websocket/outbound/WebSocketDeliveryClassTest.java` + + **Interfaces:** + - Consumes: Typed response/event와 message descriptor. + - Produces: drop 가능 여부를 schema/catalog에 고정한 outbound message. + + **Implementation requirements:** + - critical response를 lossy로 등록하지 않는다. +- coalesce는 stable key가 있는 snapshot/presence에만 허용한다. +- delivery class는 runtime 호출자가 임의 override하지 못한다. + + - [ ] **Step 1: Write the failing test** + + ```java + package io.backend.skeleton.websocket.outbound; + +class WebSocketDeliveryClassTest { + @org.junit.jupiter.api.Test + void valuesAreStable() { + org.assertj.core.api.Assertions.assertThat(WebSocketDeliveryClass.values()) + .extracting(java.lang.Enum::name) + .containsExactly("LOSSLESS_CRITICAL", "LOSSLESS_RESUMABLE", "LOSSY_DROP_ALLOWED", "LOSSY_COALESCE_BY_KEY"); + } +} + ``` + + - [ ] **Step 2: Run the focused test and verify the failure** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-core-api:test --tests 'io.backend.skeleton.websocket.outbound.WebSocketDeliveryClassTest' + ``` + + Expected: FAIL because `WebSocketDeliveryClass` and its required policy contract do not exist yet. + + - [ ] **Step 3: Implement the smallest complete production contract** + + ```java + package io.backend.skeleton.websocket.outbound; + +public enum WebSocketDeliveryClass { + LOSSLESS_CRITICAL, + LOSSLESS_RESUMABLE, + LOSSY_DROP_ALLOWED, + LOSSY_COALESCE_BY_KEY +} + ``` + + Implement every listed production file with the exact public names, boundaries and invariants above. Keep raw transport sessions, credentials, payloads, database types and dynamic identifiers out of the public contract unless this task explicitly defines a bounded adapter type. + + - [ ] **Step 4: Run the focused test and the owning suite** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-core-api:test --tests 'io.backend.skeleton.websocket.outbound.WebSocketDeliveryClassTest' + ./gradlew websocketStableTest + ``` + + Expected: PASS for the focused test and the aggregate Stable suite. + + - [ ] **Step 5: Commit the independently reviewable change** + + ```bash + git add 'modules/websocket/websocket-core-api/src/main/java/io/backend/skeleton/websocket/outbound/WebSocketDeliveryClass.java' 'modules/websocket/websocket-core-api/src/test/java/io/backend/skeleton/websocket/outbound/WebSocketDeliveryClassTest.java' 'modules/websocket/websocket-core-api/src/main/java/io/backend/skeleton/websocket/outbound/WebSocketOutboundPriority.java' 'modules/websocket/websocket-core-api/src/main/java/io/backend/skeleton/websocket/outbound/WebSocketOutboundMessage.java' + git commit -m "feat: outbound-priority" + ``` + +### Task 25: Outbound Queue·Backpressure + + **Files:** + - Create: `modules/websocket/websocket-session/src/main/java/io/backend/skeleton/websocket/session/WebSocketOutboundQueueSnapshot.java` +- Create: `modules/websocket/websocket-session/src/main/java/io/backend/skeleton/websocket/session/WebSocketOutboundQueue.java` +- Create: `modules/websocket/websocket-session/src/main/java/io/backend/skeleton/websocket/session/WebSocketOverflowPolicy.java` +- Test: `modules/websocket/websocket-session/src/test/java/io/backend/skeleton/websocket/session/WebSocketOutboundQueueSnapshotTest.java` + + **Interfaces:** + - Consumes: Delivery class와 endpoint resource budget. + - Produces: count·bytes·priority·coalesce를 적용하는 bounded queue. + + **Implementation requirements:** + - LOSSLESS overflow는 silent drop하지 않고 close/reconcile한다. +- DROP 정책은 catalog에서 허용된 message type에만 적용한다. +- queue snapshot에 payload나 dynamic identifiers를 포함하지 않는다. + + - [ ] **Step 1: Write the failing test** + + ```java + package io.backend.skeleton.websocket.session; + +class WebSocketOutboundQueueSnapshotTest { + @org.junit.jupiter.api.Test + void recordShapeIsStable() { + var names = java.util.Arrays.stream(WebSocketOutboundQueueSnapshot.class.getRecordComponents()) + .map(java.lang.reflect.RecordComponent::getName) + .toList(); + org.assertj.core.api.Assertions.assertThat(names) + .containsExactly("messageCount", "byteCount", "droppedCount", "overflowed"); + } +} + ``` + + - [ ] **Step 2: Run the focused test and verify the failure** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-session:test --tests 'io.backend.skeleton.websocket.session.WebSocketOutboundQueueSnapshotTest' + ``` + + Expected: FAIL because `WebSocketOutboundQueueSnapshot` and its required policy contract do not exist yet. + + - [ ] **Step 3: Implement the smallest complete production contract** + + ```java + package io.backend.skeleton.websocket.session; + +public record WebSocketOutboundQueueSnapshot( + int messageCount, + long byteCount, + long droppedCount, + boolean overflowed) { + public WebSocketOutboundQueueSnapshot { + + } +} + ``` + + Implement every listed production file with the exact public names, boundaries and invariants above. Keep raw transport sessions, credentials, payloads, database types and dynamic identifiers out of the public contract unless this task explicitly defines a bounded adapter type. + + - [ ] **Step 4: Run the focused test and the owning suite** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-session:test --tests 'io.backend.skeleton.websocket.session.WebSocketOutboundQueueSnapshotTest' + ./gradlew websocketStableTest + ``` + + Expected: PASS for the focused test and the aggregate Stable suite. + + - [ ] **Step 5: Commit the independently reviewable change** + + ```bash + git add 'modules/websocket/websocket-session/src/main/java/io/backend/skeleton/websocket/session/WebSocketOutboundQueueSnapshot.java' 'modules/websocket/websocket-session/src/test/java/io/backend/skeleton/websocket/session/WebSocketOutboundQueueSnapshotTest.java' 'modules/websocket/websocket-session/src/main/java/io/backend/skeleton/websocket/session/WebSocketOutboundQueue.java' 'modules/websocket/websocket-session/src/main/java/io/backend/skeleton/websocket/session/WebSocketOverflowPolicy.java' + git commit -m "feat: outbound-queue-backpressure" + ``` + +### Task 26: Serialized Outbound Writer + + **Files:** + - Create: `modules/websocket/websocket-session/src/main/java/io/backend/skeleton/websocket/session/WebSocketWriteResult.java` +- Create: `modules/websocket/websocket-session/src/main/java/io/backend/skeleton/websocket/session/WebSocketSerializedWriter.java` +- Create: `modules/websocket/websocket-session/src/main/java/io/backend/skeleton/websocket/session/WebSocketTransportWriter.java` +- Test: `modules/websocket/websocket-session/src/test/java/io/backend/skeleton/websocket/session/WebSocketWriteResultTest.java` + + **Interfaces:** + - Consumes: Bounded queue와 transport-specific writer. + - Produces: Session당 단일 write owner와 send-time enforcement. + + **Implementation requirements:** + - Application thread의 raw send 호출을 금지한다. +- write 반환을 client receive evidence로 승격하지 않는다. +- send stall 초과 시 queue를 정리하고 connection을 close한다. + + - [ ] **Step 1: Write the failing test** + + ```java + package io.backend.skeleton.websocket.session; + +class WebSocketWriteResultTest { + @org.junit.jupiter.api.Test + void recordShapeIsStable() { + var names = java.util.Arrays.stream(WebSocketWriteResult.class.getRecordComponents()) + .map(java.lang.reflect.RecordComponent::getName) + .toList(); + org.assertj.core.api.Assertions.assertThat(names) + .containsExactly("messageId", "bytes", "elapsed", "evidence"); + } +} + ``` + + - [ ] **Step 2: Run the focused test and verify the failure** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-session:test --tests 'io.backend.skeleton.websocket.session.WebSocketWriteResultTest' + ``` + + Expected: FAIL because `WebSocketWriteResult` and its required policy contract do not exist yet. + + - [ ] **Step 3: Implement the smallest complete production contract** + + ```java + package io.backend.skeleton.websocket.session; + +public record WebSocketWriteResult( + String messageId, + long bytes, + java.time.Duration elapsed, + String evidence) { + public WebSocketWriteResult { + java.util.Objects.requireNonNull(messageId, "messageId"); + java.util.Objects.requireNonNull(elapsed, "elapsed"); + java.util.Objects.requireNonNull(evidence, "evidence"); + } +} + ``` + + Implement every listed production file with the exact public names, boundaries and invariants above. Keep raw transport sessions, credentials, payloads, database types and dynamic identifiers out of the public contract unless this task explicitly defines a bounded adapter type. + + - [ ] **Step 4: Run the focused test and the owning suite** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-session:test --tests 'io.backend.skeleton.websocket.session.WebSocketWriteResultTest' + ./gradlew websocketStableTest + ``` + + Expected: PASS for the focused test and the aggregate Stable suite. + + - [ ] **Step 5: Commit the independently reviewable change** + + ```bash + git add 'modules/websocket/websocket-session/src/main/java/io/backend/skeleton/websocket/session/WebSocketWriteResult.java' 'modules/websocket/websocket-session/src/test/java/io/backend/skeleton/websocket/session/WebSocketWriteResultTest.java' 'modules/websocket/websocket-session/src/main/java/io/backend/skeleton/websocket/session/WebSocketSerializedWriter.java' 'modules/websocket/websocket-session/src/main/java/io/backend/skeleton/websocket/session/WebSocketTransportWriter.java' + git commit -m "feat: serialized-outbound-writer" + ``` + +### Task 27: Global Buffered Bytes Admission + + **Files:** + - Create: `modules/websocket/websocket-resilience/src/main/java/io/backend/skeleton/websocket/resilience/WebSocketGlobalBufferSnapshot.java` +- Create: `modules/websocket/websocket-resilience/src/main/java/io/backend/skeleton/websocket/resilience/WebSocketGlobalBufferAdmission.java` +- Test: `modules/websocket/websocket-resilience/src/test/java/io/backend/skeleton/websocket/resilience/WebSocketGlobalBufferSnapshotTest.java` + + **Interfaces:** + - Consumes: Session queue byte delta와 endpoint budgets. + - Produces: 전역 memory hard limit와 신규 queue admission decision. + + **Implementation requirements:** + - global limit 초과 시 신규 low-priority message를 거부하거나 session을 종료한다. +- 한 tenant가 전체 buffer를 독점하지 못하도록 profile별 quota를 지원한다. +- metric tag에 tenant raw value를 넣지 않는다. + + - [ ] **Step 1: Write the failing test** + + ```java + package io.backend.skeleton.websocket.resilience; + +class WebSocketGlobalBufferSnapshotTest { + @org.junit.jupiter.api.Test + void recordShapeIsStable() { + var names = java.util.Arrays.stream(WebSocketGlobalBufferSnapshot.class.getRecordComponents()) + .map(java.lang.reflect.RecordComponent::getName) + .toList(); + org.assertj.core.api.Assertions.assertThat(names) + .containsExactly("usedBytes", "maxBytes", "activeQueues", "admissionOpen"); + } +} + ``` + + - [ ] **Step 2: Run the focused test and verify the failure** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-resilience:test --tests 'io.backend.skeleton.websocket.resilience.WebSocketGlobalBufferSnapshotTest' + ``` + + Expected: FAIL because `WebSocketGlobalBufferSnapshot` and its required policy contract do not exist yet. + + - [ ] **Step 3: Implement the smallest complete production contract** + + ```java + package io.backend.skeleton.websocket.resilience; + +public record WebSocketGlobalBufferSnapshot( + long usedBytes, + long maxBytes, + int activeQueues, + boolean admissionOpen) { + public WebSocketGlobalBufferSnapshot { + + } +} + ``` + + Implement every listed production file with the exact public names, boundaries and invariants above. Keep raw transport sessions, credentials, payloads, database types and dynamic identifiers out of the public contract unless this task explicitly defines a bounded adapter type. + + - [ ] **Step 4: Run the focused test and the owning suite** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-resilience:test --tests 'io.backend.skeleton.websocket.resilience.WebSocketGlobalBufferSnapshotTest' + ./gradlew websocketStableTest + ``` + + Expected: PASS for the focused test and the aggregate Stable suite. + + - [ ] **Step 5: Commit the independently reviewable change** + + ```bash + git add 'modules/websocket/websocket-resilience/src/main/java/io/backend/skeleton/websocket/resilience/WebSocketGlobalBufferSnapshot.java' 'modules/websocket/websocket-resilience/src/test/java/io/backend/skeleton/websocket/resilience/WebSocketGlobalBufferSnapshotTest.java' 'modules/websocket/websocket-resilience/src/main/java/io/backend/skeleton/websocket/resilience/WebSocketGlobalBufferAdmission.java' + git commit -m "feat: global-buffered-bytes-admission" + ``` + +### Task 28: Ordering Profile + + **Files:** + - Create: `modules/websocket/websocket-core-api/src/main/java/io/backend/skeleton/websocket/ordering/WebSocketOrderingProfile.java` +- Create: `modules/websocket/websocket-core-api/src/main/java/io/backend/skeleton/websocket/ordering/WebSocketOrderingKey.java` +- Create: `modules/websocket/websocket-core-api/src/main/java/io/backend/skeleton/websocket/ordering/WebSocketOrderedDispatcher.java` +- Test: `modules/websocket/websocket-core-api/src/test/java/io/backend/skeleton/websocket/ordering/WebSocketOrderingProfileTest.java` + + **Interfaces:** + - Consumes: Endpoint profile과 message descriptor. + - Produces: session·stream 단위 직렬 dispatch contract. + + **Implementation requirements:** + - ordering profile은 endpoint/message catalog에서 고정한다. +- UNORDERED message에 sequence 의존 업무를 허용하지 않는다. +- ordering이 throughput에 미치는 영향을 performance gate에서 측정한다. + + - [ ] **Step 1: Write the failing test** + + ```java + package io.backend.skeleton.websocket.ordering; + +class WebSocketOrderingProfileTest { + @org.junit.jupiter.api.Test + void valuesAreStable() { + org.assertj.core.api.Assertions.assertThat(WebSocketOrderingProfile.values()) + .extracting(java.lang.Enum::name) + .containsExactly("UNORDERED_LOW_LATENCY", "SESSION_ORDERED", "STREAM_KEY_ORDERED"); + } +} + ``` + + - [ ] **Step 2: Run the focused test and verify the failure** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-core-api:test --tests 'io.backend.skeleton.websocket.ordering.WebSocketOrderingProfileTest' + ``` + + Expected: FAIL because `WebSocketOrderingProfile` and its required policy contract do not exist yet. + + - [ ] **Step 3: Implement the smallest complete production contract** + + ```java + package io.backend.skeleton.websocket.ordering; + +public enum WebSocketOrderingProfile { + UNORDERED_LOW_LATENCY, + SESSION_ORDERED, + STREAM_KEY_ORDERED +} + ``` + + Implement every listed production file with the exact public names, boundaries and invariants above. Keep raw transport sessions, credentials, payloads, database types and dynamic identifiers out of the public contract unless this task explicitly defines a bounded adapter type. + + - [ ] **Step 4: Run the focused test and the owning suite** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-core-api:test --tests 'io.backend.skeleton.websocket.ordering.WebSocketOrderingProfileTest' + ./gradlew websocketStableTest + ``` + + Expected: PASS for the focused test and the aggregate Stable suite. + + - [ ] **Step 5: Commit the independently reviewable change** + + ```bash + git add 'modules/websocket/websocket-core-api/src/main/java/io/backend/skeleton/websocket/ordering/WebSocketOrderingProfile.java' 'modules/websocket/websocket-core-api/src/test/java/io/backend/skeleton/websocket/ordering/WebSocketOrderingProfileTest.java' 'modules/websocket/websocket-core-api/src/main/java/io/backend/skeleton/websocket/ordering/WebSocketOrderingKey.java' 'modules/websocket/websocket-core-api/src/main/java/io/backend/skeleton/websocket/ordering/WebSocketOrderedDispatcher.java' + git commit -m "feat: ordering-profile" + ``` + +### Task 29: Sequence와 Gap Detection + + **Files:** + - Create: `modules/websocket/websocket-resilience/src/main/java/io/backend/skeleton/websocket/resilience/WebSocketSequenceDecision.java` +- Create: `modules/websocket/websocket-resilience/src/main/java/io/backend/skeleton/websocket/resilience/WebSocketStreamSequence.java` +- Create: `modules/websocket/websocket-resilience/src/main/java/io/backend/skeleton/websocket/resilience/WebSocketGapDetector.java` +- Test: `modules/websocket/websocket-resilience/src/test/java/io/backend/skeleton/websocket/resilience/WebSocketSequenceDecisionTest.java` + + **Interfaces:** + - Consumes: streamId·sequence와 ordering profile. + - Produces: lastAppliedSequence 기반 duplicate/gap 판정. + + **Implementation requirements:** + - lastReceived와 lastApplied를 구분한다. +- gap 발생 후 incremental apply를 계속하지 않는다. +- Stable에서는 snapshot/replay 실행 대신 GAP evidence와 close/reconnect hint를 반환한다. + + - [ ] **Step 1: Write the failing test** + + ```java + package io.backend.skeleton.websocket.resilience; + +class WebSocketSequenceDecisionTest { + @org.junit.jupiter.api.Test + void valuesAreStable() { + org.assertj.core.api.Assertions.assertThat(WebSocketSequenceDecision.values()) + .extracting(java.lang.Enum::name) + .containsExactly("DUPLICATE", "NEXT", "GAP", "INVALID_STREAM"); + } +} + ``` + + - [ ] **Step 2: Run the focused test and verify the failure** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-resilience:test --tests 'io.backend.skeleton.websocket.resilience.WebSocketSequenceDecisionTest' + ``` + + Expected: FAIL because `WebSocketSequenceDecision` and its required policy contract do not exist yet. + + - [ ] **Step 3: Implement the smallest complete production contract** + + ```java + package io.backend.skeleton.websocket.resilience; + +public enum WebSocketSequenceDecision { + DUPLICATE, + NEXT, + GAP, + INVALID_STREAM +} + ``` + + Implement every listed production file with the exact public names, boundaries and invariants above. Keep raw transport sessions, credentials, payloads, database types and dynamic identifiers out of the public contract unless this task explicitly defines a bounded adapter type. + + - [ ] **Step 4: Run the focused test and the owning suite** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-resilience:test --tests 'io.backend.skeleton.websocket.resilience.WebSocketSequenceDecisionTest' + ./gradlew websocketStableTest + ``` + + Expected: PASS for the focused test and the aggregate Stable suite. + + - [ ] **Step 5: Commit the independently reviewable change** + + ```bash + git add 'modules/websocket/websocket-resilience/src/main/java/io/backend/skeleton/websocket/resilience/WebSocketSequenceDecision.java' 'modules/websocket/websocket-resilience/src/test/java/io/backend/skeleton/websocket/resilience/WebSocketSequenceDecisionTest.java' 'modules/websocket/websocket-resilience/src/main/java/io/backend/skeleton/websocket/resilience/WebSocketStreamSequence.java' 'modules/websocket/websocket-resilience/src/main/java/io/backend/skeleton/websocket/resilience/WebSocketGapDetector.java' + git commit -m "feat: sequence-gap-detection" + ``` + +### Task 30: Heartbeat·Idle Policy + + **Files:** + - Create: `modules/websocket/websocket-session/src/main/java/io/backend/skeleton/websocket/session/WebSocketHeartbeatPolicy.java` +- Create: `modules/websocket/websocket-session/src/main/java/io/backend/skeleton/websocket/session/WebSocketHeartbeatScheduler.java` +- Create: `modules/websocket/websocket-session/src/main/java/io/backend/skeleton/websocket/session/WebSocketLivenessTracker.java` +- Test: `modules/websocket/websocket-session/src/test/java/io/backend/skeleton/websocket/session/WebSocketHeartbeatPolicyTest.java` + + **Interfaces:** + - Consumes: Connection context와 endpoint profile. + - Produces: Ping/Pong·idle·half-open evidence contract. + + **Implementation requirements:** + - interval < timeout < proxyReadTimeout을 강제한다. +- heartbeat와 presence TTL을 동일시하지 않는다. +- missed heartbeat는 4408 close로 연결한다. + + - [ ] **Step 1: Write the failing test** + + ```java + package io.backend.skeleton.websocket.session; + +class WebSocketHeartbeatPolicyTest { + @org.junit.jupiter.api.Test + void recordShapeIsStable() { + var names = java.util.Arrays.stream(WebSocketHeartbeatPolicy.class.getRecordComponents()) + .map(java.lang.reflect.RecordComponent::getName) + .toList(); + org.assertj.core.api.Assertions.assertThat(names) + .containsExactly("interval", "timeout", "proxyReadTimeout", "maxMissedHeartbeats"); + } +} + ``` + + - [ ] **Step 2: Run the focused test and verify the failure** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-session:test --tests 'io.backend.skeleton.websocket.session.WebSocketHeartbeatPolicyTest' + ``` + + Expected: FAIL because `WebSocketHeartbeatPolicy` and its required policy contract do not exist yet. + + - [ ] **Step 3: Implement the smallest complete production contract** + + ```java + package io.backend.skeleton.websocket.session; + +public record WebSocketHeartbeatPolicy( + java.time.Duration interval, + java.time.Duration timeout, + java.time.Duration proxyReadTimeout, + int maxMissedHeartbeats) { + public WebSocketHeartbeatPolicy { + java.util.Objects.requireNonNull(interval, "interval"); + java.util.Objects.requireNonNull(timeout, "timeout"); + java.util.Objects.requireNonNull(proxyReadTimeout, "proxyReadTimeout"); + } +} + ``` + + Implement every listed production file with the exact public names, boundaries and invariants above. Keep raw transport sessions, credentials, payloads, database types and dynamic identifiers out of the public contract unless this task explicitly defines a bounded adapter type. + + - [ ] **Step 4: Run the focused test and the owning suite** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-session:test --tests 'io.backend.skeleton.websocket.session.WebSocketHeartbeatPolicyTest' + ./gradlew websocketStableTest + ``` + + Expected: PASS for the focused test and the aggregate Stable suite. + + - [ ] **Step 5: Commit the independently reviewable change** + + ```bash + git add 'modules/websocket/websocket-session/src/main/java/io/backend/skeleton/websocket/session/WebSocketHeartbeatPolicy.java' 'modules/websocket/websocket-session/src/test/java/io/backend/skeleton/websocket/session/WebSocketHeartbeatPolicyTest.java' 'modules/websocket/websocket-session/src/main/java/io/backend/skeleton/websocket/session/WebSocketHeartbeatScheduler.java' 'modules/websocket/websocket-session/src/main/java/io/backend/skeleton/websocket/session/WebSocketLivenessTracker.java' + git commit -m "feat: heartbeat-idle-policy" + ``` + +### Task 31: Credential Expiry와 Max Connection Age + + **Files:** + - Create: `modules/websocket/websocket-security/src/main/java/io/backend/skeleton/websocket/security/WebSocketConnectionLifetimePolicy.java` +- Create: `modules/websocket/websocket-security/src/main/java/io/backend/skeleton/websocket/security/WebSocketCredentialExpiryEnforcer.java` +- Test: `modules/websocket/websocket-security/src/test/java/io/backend/skeleton/websocket/security/WebSocketConnectionLifetimePolicyTest.java` + + **Interfaces:** + - Consumes: Connection credential expiry와 heartbeat scheduler. + - Produces: credential expiry·permission revoke·max-age close policy. + + **Implementation requirements:** + - Stable에서는 mid-connection reauth를 수행하지 않는다. +- expiry 전에 typed reconnect hint를 보낼 수 있다. +- 권한 회수 event는 session close를 trigger할 수 있다. + + - [ ] **Step 1: Write the failing test** + + ```java + package io.backend.skeleton.websocket.security; + +class WebSocketConnectionLifetimePolicyTest { + @org.junit.jupiter.api.Test + void recordShapeIsStable() { + var names = java.util.Arrays.stream(WebSocketConnectionLifetimePolicy.class.getRecordComponents()) + .map(java.lang.reflect.RecordComponent::getName) + .toList(); + org.assertj.core.api.Assertions.assertThat(names) + .containsExactly("maxAge", "credentialExpiryGrace", "reconnectHint"); + } +} + ``` + + - [ ] **Step 2: Run the focused test and verify the failure** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-security:test --tests 'io.backend.skeleton.websocket.security.WebSocketConnectionLifetimePolicyTest' + ``` + + Expected: FAIL because `WebSocketConnectionLifetimePolicy` and its required policy contract do not exist yet. + + - [ ] **Step 3: Implement the smallest complete production contract** + + ```java + package io.backend.skeleton.websocket.security; + +public record WebSocketConnectionLifetimePolicy( + java.time.Duration maxAge, + java.time.Duration credentialExpiryGrace, + java.time.Duration reconnectHint) { + public WebSocketConnectionLifetimePolicy { + java.util.Objects.requireNonNull(maxAge, "maxAge"); + java.util.Objects.requireNonNull(credentialExpiryGrace, "credentialExpiryGrace"); + java.util.Objects.requireNonNull(reconnectHint, "reconnectHint"); + } +} + ``` + + Implement every listed production file with the exact public names, boundaries and invariants above. Keep raw transport sessions, credentials, payloads, database types and dynamic identifiers out of the public contract unless this task explicitly defines a bounded adapter type. + + - [ ] **Step 4: Run the focused test and the owning suite** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-security:test --tests 'io.backend.skeleton.websocket.security.WebSocketConnectionLifetimePolicyTest' + ./gradlew websocketStableTest + ``` + + Expected: PASS for the focused test and the aggregate Stable suite. + + - [ ] **Step 5: Commit the independently reviewable change** + + ```bash + git add 'modules/websocket/websocket-security/src/main/java/io/backend/skeleton/websocket/security/WebSocketConnectionLifetimePolicy.java' 'modules/websocket/websocket-security/src/test/java/io/backend/skeleton/websocket/security/WebSocketConnectionLifetimePolicyTest.java' 'modules/websocket/websocket-security/src/main/java/io/backend/skeleton/websocket/security/WebSocketCredentialExpiryEnforcer.java' + git commit -m "feat: credential-expiry-max-connection-age" + ``` + +### Task 32: Connection Drain과 Close Orchestration + + **Files:** + - Create: `modules/websocket/websocket-session/src/main/java/io/backend/skeleton/websocket/session/WebSocketDrainPolicy.java` +- Create: `modules/websocket/websocket-session/src/main/java/io/backend/skeleton/websocket/session/WebSocketConnectionDrainer.java` +- Create: `modules/websocket/websocket-session/src/main/java/io/backend/skeleton/websocket/session/WebSocketCloseCoordinator.java` +- Test: `modules/websocket/websocket-session/src/test/java/io/backend/skeleton/websocket/session/WebSocketDrainPolicyTest.java` + + **Interfaces:** + - Consumes: Session registry·queue·lifetime policy. + - Produces: readiness off→new handshake reject→queue drain→1012 close contract. + + **Implementation requirements:** + - draining 중 신규 command를 거부한다. +- messageDrainTime 이후 강제 close한다. +- restart close에는 bounded reconnect hint만 포함한다. + + - [ ] **Step 1: Write the failing test** + + ```java + package io.backend.skeleton.websocket.session; + +class WebSocketDrainPolicyTest { + @org.junit.jupiter.api.Test + void recordShapeIsStable() { + var names = java.util.Arrays.stream(WebSocketDrainPolicy.class.getRecordComponents()) + .map(java.lang.reflect.RecordComponent::getName) + .toList(); + org.assertj.core.api.Assertions.assertThat(names) + .containsExactly("handshakeDrainTime", "messageDrainTime", "restartCloseCode"); + } +} + ``` + + - [ ] **Step 2: Run the focused test and verify the failure** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-session:test --tests 'io.backend.skeleton.websocket.session.WebSocketDrainPolicyTest' + ``` + + Expected: FAIL because `WebSocketDrainPolicy` and its required policy contract do not exist yet. + + - [ ] **Step 3: Implement the smallest complete production contract** + + ```java + package io.backend.skeleton.websocket.session; + +public record WebSocketDrainPolicy( + java.time.Duration handshakeDrainTime, + java.time.Duration messageDrainTime, + int restartCloseCode) { + public WebSocketDrainPolicy { + java.util.Objects.requireNonNull(handshakeDrainTime, "handshakeDrainTime"); + java.util.Objects.requireNonNull(messageDrainTime, "messageDrainTime"); + } +} + ``` + + Implement every listed production file with the exact public names, boundaries and invariants above. Keep raw transport sessions, credentials, payloads, database types and dynamic identifiers out of the public contract unless this task explicitly defines a bounded adapter type. + + - [ ] **Step 4: Run the focused test and the owning suite** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-session:test --tests 'io.backend.skeleton.websocket.session.WebSocketDrainPolicyTest' + ./gradlew websocketStableTest + ``` + + Expected: PASS for the focused test and the aggregate Stable suite. + + - [ ] **Step 5: Commit the independently reviewable change** + + ```bash + git add 'modules/websocket/websocket-session/src/main/java/io/backend/skeleton/websocket/session/WebSocketDrainPolicy.java' 'modules/websocket/websocket-session/src/test/java/io/backend/skeleton/websocket/session/WebSocketDrainPolicyTest.java' 'modules/websocket/websocket-session/src/main/java/io/backend/skeleton/websocket/session/WebSocketConnectionDrainer.java' 'modules/websocket/websocket-session/src/main/java/io/backend/skeleton/websocket/session/WebSocketCloseCoordinator.java' + git commit -m "feat: connection-drain-close-orchestration" + ``` + +### Task 33: Servlet Raw WebSocket Runtime + + **Files:** + - Create: `modules/websocket/websocket-servlet/src/main/java/io/backend/skeleton/websocket/servlet/ServletWebSocketRuntimeProfile.java` +- Create: `modules/websocket/websocket-servlet/src/main/java/io/backend/skeleton/websocket/servlet/ServletTypedWebSocketHandler.java` +- Create: `modules/websocket/websocket-servlet/src/main/java/io/backend/skeleton/websocket/servlet/ServletHandshakeAdapter.java` +- Test: `modules/websocket/websocket-servlet/src/test/java/io/backend/skeleton/websocket/servlet/ServletWebSocketRuntimeProfileTest.java` + + **Interfaces:** + - Consumes: Stable protocol·security·session contracts. + - Produces: Spring Servlet WebSocket adapter without leaking WebSocketSession. + + **Implementation requirements:** + - Tomcat profile을 default로 등록한다. +- blocking Application handler는 bounded executor를 사용한다. +- container callback에서 전체 payload를 중복 materialize하지 않는다. + + - [ ] **Step 1: Write the failing test** + + ```java + package io.backend.skeleton.websocket.servlet; + +class ServletWebSocketRuntimeProfileTest { + @org.junit.jupiter.api.Test + void recordShapeIsStable() { + var names = java.util.Arrays.stream(ServletWebSocketRuntimeProfile.class.getRecordComponents()) + .map(java.lang.reflect.RecordComponent::getName) + .toList(); + org.assertj.core.api.Assertions.assertThat(names) + .containsExactly("container", "maxTextMessageBytes", "sendTimeLimit", "sendBufferBytes"); + } +} + ``` + + - [ ] **Step 2: Run the focused test and verify the failure** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-servlet:test --tests 'io.backend.skeleton.websocket.servlet.ServletWebSocketRuntimeProfileTest' + ``` + + Expected: FAIL because `ServletWebSocketRuntimeProfile` and its required policy contract do not exist yet. + + - [ ] **Step 3: Implement the smallest complete production contract** + + ```java + package io.backend.skeleton.websocket.servlet; + +public record ServletWebSocketRuntimeProfile( + String container, + int maxTextMessageBytes, + java.time.Duration sendTimeLimit, + long sendBufferBytes) { + public ServletWebSocketRuntimeProfile { + java.util.Objects.requireNonNull(container, "container"); + java.util.Objects.requireNonNull(sendTimeLimit, "sendTimeLimit"); + } +} + ``` + + Implement every listed production file with the exact public names, boundaries and invariants above. Keep raw transport sessions, credentials, payloads, database types and dynamic identifiers out of the public contract unless this task explicitly defines a bounded adapter type. + + - [ ] **Step 4: Run the focused test and the owning suite** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-servlet:test --tests 'io.backend.skeleton.websocket.servlet.ServletWebSocketRuntimeProfileTest' + ./gradlew websocketStableTest + ``` + + Expected: PASS for the focused test and the aggregate Stable suite. + + - [ ] **Step 5: Commit the independently reviewable change** + + ```bash + git add 'modules/websocket/websocket-servlet/src/main/java/io/backend/skeleton/websocket/servlet/ServletWebSocketRuntimeProfile.java' 'modules/websocket/websocket-servlet/src/test/java/io/backend/skeleton/websocket/servlet/ServletWebSocketRuntimeProfileTest.java' 'modules/websocket/websocket-servlet/src/main/java/io/backend/skeleton/websocket/servlet/ServletTypedWebSocketHandler.java' 'modules/websocket/websocket-servlet/src/main/java/io/backend/skeleton/websocket/servlet/ServletHandshakeAdapter.java' + git commit -m "feat: servlet-raw-websocket-runtime" + ``` + +### Task 34: Servlet Concurrent Session Decorator + + **Files:** + - Create: `modules/websocket/websocket-servlet/src/main/java/io/backend/skeleton/websocket/servlet/ServletSessionWritePolicy.java` +- Create: `modules/websocket/websocket-servlet/src/main/java/io/backend/skeleton/websocket/servlet/ServletSerializedSessionWriter.java` +- Test: `modules/websocket/websocket-servlet/src/test/java/io/backend/skeleton/websocket/servlet/ServletSessionWritePolicyTest.java` + + **Interfaces:** + - Consumes: Serialized writer와 Servlet transport session. + - Produces: `ConcurrentWebSocketSessionDecorator` 기반 single writer adapter. + + **Implementation requirements:** + - 동시 send를 decorator 밖에서 수행하지 않는다. +- buffer overflow는 delivery class 정책으로 변환한다. +- transport session을 domain/application bean으로 노출하지 않는다. + + - [ ] **Step 1: Write the failing test** + + ```java + package io.backend.skeleton.websocket.servlet; + +class ServletSessionWritePolicyTest { + @org.junit.jupiter.api.Test + void recordShapeIsStable() { + var names = java.util.Arrays.stream(ServletSessionWritePolicy.class.getRecordComponents()) + .map(java.lang.reflect.RecordComponent::getName) + .toList(); + org.assertj.core.api.Assertions.assertThat(names) + .containsExactly("sendTimeLimit", "bufferSizeLimit", "overflowStrategy"); + } +} + ``` + + - [ ] **Step 2: Run the focused test and verify the failure** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-servlet:test --tests 'io.backend.skeleton.websocket.servlet.ServletSessionWritePolicyTest' + ``` + + Expected: FAIL because `ServletSessionWritePolicy` and its required policy contract do not exist yet. + + - [ ] **Step 3: Implement the smallest complete production contract** + + ```java + package io.backend.skeleton.websocket.servlet; + +public record ServletSessionWritePolicy( + java.time.Duration sendTimeLimit, + long bufferSizeLimit, + String overflowStrategy) { + public ServletSessionWritePolicy { + java.util.Objects.requireNonNull(sendTimeLimit, "sendTimeLimit"); + java.util.Objects.requireNonNull(overflowStrategy, "overflowStrategy"); + } +} + ``` + + Implement every listed production file with the exact public names, boundaries and invariants above. Keep raw transport sessions, credentials, payloads, database types and dynamic identifiers out of the public contract unless this task explicitly defines a bounded adapter type. + + - [ ] **Step 4: Run the focused test and the owning suite** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-servlet:test --tests 'io.backend.skeleton.websocket.servlet.ServletSessionWritePolicyTest' + ./gradlew websocketStableTest + ``` + + Expected: PASS for the focused test and the aggregate Stable suite. + + - [ ] **Step 5: Commit the independently reviewable change** + + ```bash + git add 'modules/websocket/websocket-servlet/src/main/java/io/backend/skeleton/websocket/servlet/ServletSessionWritePolicy.java' 'modules/websocket/websocket-servlet/src/test/java/io/backend/skeleton/websocket/servlet/ServletSessionWritePolicyTest.java' 'modules/websocket/websocket-servlet/src/main/java/io/backend/skeleton/websocket/servlet/ServletSerializedSessionWriter.java' + git commit -m "feat: servlet-concurrent-session-decorator" + ``` + +### Task 35: Tomcat 실제 Runtime Contract + + **Files:** + - Create: `modules/websocket/websocket-testkit-servlet/src/main/java/io/backend/skeleton/websocket/servlet/TomcatWebSocketContractProfile.java` +- Create: `modules/websocket/websocket-testkit-servlet/src/test/java/io/backend/skeleton/websocket/servlet/TomcatWebSocketRuntimeContractTest.java` +- Test: `modules/websocket/websocket-testkit-servlet/src/test/java/io/backend/skeleton/websocket/servlet/TomcatWebSocketContractProfileTest.java` + + **Interfaces:** + - Consumes: Servlet runtime과 test endpoint fixture. + - Produces: Tomcat 11의 fragmentation·size·concurrent write·close evidence. + + **Implementation requirements:** + - 실제 random port server를 시작한다. +- mock session으로 대체하지 않는다. +- TLS/Nginx는 proxy task에서 추가 검증한다. + + - [ ] **Step 1: Write the failing test** + + ```java + package io.backend.skeleton.websocket.servlet; + +class TomcatWebSocketContractProfileTest { + @org.junit.jupiter.api.Test + void policyNameIsStable() { + org.assertj.core.api.Assertions.assertThat(TomcatWebSocketContractProfile.policyName()) + .isEqualTo("tomcat-websocket-contract"); + } +} + ``` + + - [ ] **Step 2: Run the focused test and verify the failure** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-testkit-servlet:test --tests 'io.backend.skeleton.websocket.servlet.TomcatWebSocketContractProfileTest' + ``` + + Expected: FAIL because `TomcatWebSocketContractProfile` and its required policy contract do not exist yet. + + - [ ] **Step 3: Implement the smallest complete production contract** + + ```java + package io.backend.skeleton.websocket.servlet; + +public final class TomcatWebSocketContractProfile { + private TomcatWebSocketContractProfile() {} + + public static String policyName() { + return "tomcat-websocket-contract"; + } +} + ``` + + Implement every listed production file with the exact public names, boundaries and invariants above. Keep raw transport sessions, credentials, payloads, database types and dynamic identifiers out of the public contract unless this task explicitly defines a bounded adapter type. + + - [ ] **Step 4: Run the focused test and the owning suite** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-testkit-servlet:test --tests 'io.backend.skeleton.websocket.servlet.TomcatWebSocketContractProfileTest' + ./gradlew websocketStableTest + ``` + + Expected: PASS for the focused test and the aggregate Stable suite. + + - [ ] **Step 5: Commit the independently reviewable change** + + ```bash + git add 'modules/websocket/websocket-testkit-servlet/src/main/java/io/backend/skeleton/websocket/servlet/TomcatWebSocketContractProfile.java' 'modules/websocket/websocket-testkit-servlet/src/test/java/io/backend/skeleton/websocket/servlet/TomcatWebSocketContractProfileTest.java' 'modules/websocket/websocket-testkit-servlet/src/test/java/io/backend/skeleton/websocket/servlet/TomcatWebSocketRuntimeContractTest.java' + git commit -m "feat: tomcat-runtime-contract" + ``` + +### Task 36: Jetty 실제 Runtime Compatibility + + **Files:** + - Create: `modules/websocket/websocket-testkit-servlet/src/main/java/io/backend/skeleton/websocket/servlet/JettyWebSocketContractProfile.java` +- Create: `modules/websocket/websocket-testkit-servlet/src/test/java/io/backend/skeleton/websocket/servlet/JettyWebSocketRuntimeContractTest.java` +- Test: `modules/websocket/websocket-testkit-servlet/src/test/java/io/backend/skeleton/websocket/servlet/JettyWebSocketContractProfileTest.java` + + **Interfaces:** + - Consumes: 동일 Servlet contract suite. + - Produces: Jetty 12.1 compatibility evidence. + + **Implementation requirements:** + - Tomcat과 동일한 public contract를 실행한다. +- container-specific close·buffer 차이를 report한다. +- 차이가 Stable contract를 깨면 compatibility gate를 실패시킨다. + + - [ ] **Step 1: Write the failing test** + + ```java + package io.backend.skeleton.websocket.servlet; + +class JettyWebSocketContractProfileTest { + @org.junit.jupiter.api.Test + void policyNameIsStable() { + org.assertj.core.api.Assertions.assertThat(JettyWebSocketContractProfile.policyName()) + .isEqualTo("jetty-websocket-contract"); + } +} + ``` + + - [ ] **Step 2: Run the focused test and verify the failure** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-testkit-servlet:test --tests 'io.backend.skeleton.websocket.servlet.JettyWebSocketContractProfileTest' + ``` + + Expected: FAIL because `JettyWebSocketContractProfile` and its required policy contract do not exist yet. + + - [ ] **Step 3: Implement the smallest complete production contract** + + ```java + package io.backend.skeleton.websocket.servlet; + +public final class JettyWebSocketContractProfile { + private JettyWebSocketContractProfile() {} + + public static String policyName() { + return "jetty-websocket-contract"; + } +} + ``` + + Implement every listed production file with the exact public names, boundaries and invariants above. Keep raw transport sessions, credentials, payloads, database types and dynamic identifiers out of the public contract unless this task explicitly defines a bounded adapter type. + + - [ ] **Step 4: Run the focused test and the owning suite** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-testkit-servlet:test --tests 'io.backend.skeleton.websocket.servlet.JettyWebSocketContractProfileTest' + ./gradlew websocketStableTest + ``` + + Expected: PASS for the focused test and the aggregate Stable suite. + + - [ ] **Step 5: Commit the independently reviewable change** + + ```bash + git add 'modules/websocket/websocket-testkit-servlet/src/main/java/io/backend/skeleton/websocket/servlet/JettyWebSocketContractProfile.java' 'modules/websocket/websocket-testkit-servlet/src/test/java/io/backend/skeleton/websocket/servlet/JettyWebSocketContractProfileTest.java' 'modules/websocket/websocket-testkit-servlet/src/test/java/io/backend/skeleton/websocket/servlet/JettyWebSocketRuntimeContractTest.java' + git commit -m "feat: jetty-runtime-compatibility" + ``` + +### Task 37: WebFlux Raw WebSocket Runtime + + **Files:** + - Create: `modules/websocket/websocket-webflux/src/main/java/io/backend/skeleton/websocket/webflux/ReactiveWebSocketRuntimeProfile.java` +- Create: `modules/websocket/websocket-webflux/src/main/java/io/backend/skeleton/websocket/webflux/ReactiveTypedWebSocketHandler.java` +- Create: `modules/websocket/websocket-webflux/src/main/java/io/backend/skeleton/websocket/webflux/ReactiveHandshakeAdapter.java` +- Test: `modules/websocket/websocket-webflux/src/test/java/io/backend/skeleton/websocket/webflux/ReactiveWebSocketRuntimeProfileTest.java` + + **Interfaces:** + - Consumes: Stable protocol·security·session contracts. + - Produces: WebFlux receive/send pipeline와 bounded outbound publisher. + + **Implementation requirements:** + - Reactor Netty event loop에서 blocking JPA/SDK를 호출하지 않는다. +- cancel·disconnect를 Application cancellation signal로 전달한다. +- browser까지 end-to-end demand가 있다고 광고하지 않는다. + + - [ ] **Step 1: Write the failing test** + + ```java + package io.backend.skeleton.websocket.webflux; + +class ReactiveWebSocketRuntimeProfileTest { + @org.junit.jupiter.api.Test + void recordShapeIsStable() { + var names = java.util.Arrays.stream(ReactiveWebSocketRuntimeProfile.class.getRecordComponents()) + .map(java.lang.reflect.RecordComponent::getName) + .toList(); + org.assertj.core.api.Assertions.assertThat(names) + .containsExactly("server", "maxTextMessageBytes", "maxOutboundQueueBytes", "idleTimeout"); + } +} + ``` + + - [ ] **Step 2: Run the focused test and verify the failure** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-webflux:test --tests 'io.backend.skeleton.websocket.webflux.ReactiveWebSocketRuntimeProfileTest' + ``` + + Expected: FAIL because `ReactiveWebSocketRuntimeProfile` and its required policy contract do not exist yet. + + - [ ] **Step 3: Implement the smallest complete production contract** + + ```java + package io.backend.skeleton.websocket.webflux; + +public record ReactiveWebSocketRuntimeProfile( + String server, + int maxTextMessageBytes, + long maxOutboundQueueBytes, + java.time.Duration idleTimeout) { + public ReactiveWebSocketRuntimeProfile { + java.util.Objects.requireNonNull(server, "server"); + java.util.Objects.requireNonNull(idleTimeout, "idleTimeout"); + } +} + ``` + + Implement every listed production file with the exact public names, boundaries and invariants above. Keep raw transport sessions, credentials, payloads, database types and dynamic identifiers out of the public contract unless this task explicitly defines a bounded adapter type. + + - [ ] **Step 4: Run the focused test and the owning suite** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-webflux:test --tests 'io.backend.skeleton.websocket.webflux.ReactiveWebSocketRuntimeProfileTest' + ./gradlew websocketStableTest + ``` + + Expected: PASS for the focused test and the aggregate Stable suite. + + - [ ] **Step 5: Commit the independently reviewable change** + + ```bash + git add 'modules/websocket/websocket-webflux/src/main/java/io/backend/skeleton/websocket/webflux/ReactiveWebSocketRuntimeProfile.java' 'modules/websocket/websocket-webflux/src/test/java/io/backend/skeleton/websocket/webflux/ReactiveWebSocketRuntimeProfileTest.java' 'modules/websocket/websocket-webflux/src/main/java/io/backend/skeleton/websocket/webflux/ReactiveTypedWebSocketHandler.java' 'modules/websocket/websocket-webflux/src/main/java/io/backend/skeleton/websocket/webflux/ReactiveHandshakeAdapter.java' + git commit -m "feat: webflux-raw-websocket-runtime" + ``` + +### Task 38: WebFlux DataBuffer 수명주기 + + **Files:** + - Create: `modules/websocket/websocket-webflux/src/main/java/io/backend/skeleton/websocket/webflux/WebSocketDataBufferPolicy.java` +- Create: `modules/websocket/websocket-webflux/src/main/java/io/backend/skeleton/websocket/webflux/WebSocketDataBufferLifecycle.java` +- Test: `modules/websocket/websocket-webflux/src/test/java/io/backend/skeleton/websocket/webflux/WebSocketDataBufferPolicyTest.java` + + **Interfaces:** + - Consumes: Reactive runtime과 decoded message pipeline. + - Produces: pooled buffer retain/release·leak detection contract. + + **Implementation requirements:** + - async boundary 뒤 보관 시 명시적으로 retain한다. +- decode·cancel·error 모든 경로에서 release한다. +- buffer leak test를 실제 Reactor Netty에서 실행한다. + + - [ ] **Step 1: Write the failing test** + + ```java + package io.backend.skeleton.websocket.webflux; + +class WebSocketDataBufferPolicyTest { + @org.junit.jupiter.api.Test + void recordShapeIsStable() { + var names = java.util.Arrays.stream(WebSocketDataBufferPolicy.class.getRecordComponents()) + .map(java.lang.reflect.RecordComponent::getName) + .toList(); + org.assertj.core.api.Assertions.assertThat(names) + .containsExactly("pooled", "retainAcrossAsyncBoundary", "maxRetainedBytes"); + } +} + ``` + + - [ ] **Step 2: Run the focused test and verify the failure** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-webflux:test --tests 'io.backend.skeleton.websocket.webflux.WebSocketDataBufferPolicyTest' + ``` + + Expected: FAIL because `WebSocketDataBufferPolicy` and its required policy contract do not exist yet. + + - [ ] **Step 3: Implement the smallest complete production contract** + + ```java + package io.backend.skeleton.websocket.webflux; + +public record WebSocketDataBufferPolicy( + boolean pooled, + boolean retainAcrossAsyncBoundary, + long maxRetainedBytes) { + public WebSocketDataBufferPolicy { + + } +} + ``` + + Implement every listed production file with the exact public names, boundaries and invariants above. Keep raw transport sessions, credentials, payloads, database types and dynamic identifiers out of the public contract unless this task explicitly defines a bounded adapter type. + + - [ ] **Step 4: Run the focused test and the owning suite** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-webflux:test --tests 'io.backend.skeleton.websocket.webflux.WebSocketDataBufferPolicyTest' + ./gradlew websocketStableTest + ``` + + Expected: PASS for the focused test and the aggregate Stable suite. + + - [ ] **Step 5: Commit the independently reviewable change** + + ```bash + git add 'modules/websocket/websocket-webflux/src/main/java/io/backend/skeleton/websocket/webflux/WebSocketDataBufferPolicy.java' 'modules/websocket/websocket-webflux/src/test/java/io/backend/skeleton/websocket/webflux/WebSocketDataBufferPolicyTest.java' 'modules/websocket/websocket-webflux/src/main/java/io/backend/skeleton/websocket/webflux/WebSocketDataBufferLifecycle.java' + git commit -m "feat: webflux-databuffer" + ``` + +### Task 39: Reactor Netty 실제 Runtime Contract + + **Files:** + - Create: `modules/websocket/websocket-testkit-webflux/src/main/java/io/backend/skeleton/websocket/webflux/ReactorNettyWebSocketContractProfile.java` +- Create: `modules/websocket/websocket-testkit-webflux/src/test/java/io/backend/skeleton/websocket/webflux/ReactorNettyRuntimeContractTest.java` +- Test: `modules/websocket/websocket-testkit-webflux/src/test/java/io/backend/skeleton/websocket/webflux/ReactorNettyWebSocketContractProfileTest.java` + + **Interfaces:** + - Consumes: WebFlux runtime과 leak detector. + - Produces: 실제 Reactor Netty fragmentation·cancel·slow subscriber evidence. + + **Implementation requirements:** + - actual server/client를 사용한다. +- event-loop blocking detector를 활성화한다. +- memory leak와 queue budget을 검증한다. + + - [ ] **Step 1: Write the failing test** + + ```java + package io.backend.skeleton.websocket.webflux; + +class ReactorNettyWebSocketContractProfileTest { + @org.junit.jupiter.api.Test + void policyNameIsStable() { + org.assertj.core.api.Assertions.assertThat(ReactorNettyWebSocketContractProfile.policyName()) + .isEqualTo("reactor-netty-websocket-contract"); + } +} + ``` + + - [ ] **Step 2: Run the focused test and verify the failure** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-testkit-webflux:test --tests 'io.backend.skeleton.websocket.webflux.ReactorNettyWebSocketContractProfileTest' + ``` + + Expected: FAIL because `ReactorNettyWebSocketContractProfile` and its required policy contract do not exist yet. + + - [ ] **Step 3: Implement the smallest complete production contract** + + ```java + package io.backend.skeleton.websocket.webflux; + +public final class ReactorNettyWebSocketContractProfile { + private ReactorNettyWebSocketContractProfile() {} + + public static String policyName() { + return "reactor-netty-websocket-contract"; + } +} + ``` + + Implement every listed production file with the exact public names, boundaries and invariants above. Keep raw transport sessions, credentials, payloads, database types and dynamic identifiers out of the public contract unless this task explicitly defines a bounded adapter type. + + - [ ] **Step 4: Run the focused test and the owning suite** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-testkit-webflux:test --tests 'io.backend.skeleton.websocket.webflux.ReactorNettyWebSocketContractProfileTest' + ./gradlew websocketStableTest + ``` + + Expected: PASS for the focused test and the aggregate Stable suite. + + - [ ] **Step 5: Commit the independently reviewable change** + + ```bash + git add 'modules/websocket/websocket-testkit-webflux/src/main/java/io/backend/skeleton/websocket/webflux/ReactorNettyWebSocketContractProfile.java' 'modules/websocket/websocket-testkit-webflux/src/test/java/io/backend/skeleton/websocket/webflux/ReactorNettyWebSocketContractProfileTest.java' 'modules/websocket/websocket-testkit-webflux/src/test/java/io/backend/skeleton/websocket/webflux/ReactorNettyRuntimeContractTest.java' + git commit -m "feat: reactor-netty-runtime-contract" + ``` + +### Task 40: Metric·Trace·Safe Logging + + **Files:** + - Create: `modules/websocket/websocket-observability/src/main/java/io/backend/skeleton/websocket/observability/WebSocketObservationContext.java` +- Create: `modules/websocket/websocket-observability/src/main/java/io/backend/skeleton/websocket/observability/WebSocketObservationConvention.java` +- Create: `modules/websocket/websocket-observability/src/main/java/io/backend/skeleton/websocket/observability/WebSocketCardinalityPolicy.java` +- Create: `modules/websocket/websocket-observability/src/main/java/io/backend/skeleton/websocket/observability/WebSocketLogRedactor.java` +- Test: `modules/websocket/websocket-observability/src/test/java/io/backend/skeleton/websocket/observability/WebSocketObservationContextTest.java` + + **Interfaces:** + - Consumes: Connection·message·queue·evidence events. + - Produces: bounded metric tags와 payload/credential redaction. + + **Implementation requirements:** + - sessionId·messageId·raw user/tenant/resource를 metric tag에서 금지한다. +- payload와 ticket/token은 log에 기록하지 않는다. +- connection·message·backpressure·completion unknown을 별도 metric으로 기록한다. + + - [ ] **Step 1: Write the failing test** + + ```java + package io.backend.skeleton.websocket.observability; + +class WebSocketObservationContextTest { + @org.junit.jupiter.api.Test + void recordShapeIsStable() { + var names = java.util.Arrays.stream(WebSocketObservationContext.class.getRecordComponents()) + .map(java.lang.reflect.RecordComponent::getName) + .toList(); + org.assertj.core.api.Assertions.assertThat(names) + .containsExactly("endpointProfile", "protocol", "messageTypeCatalog", "outcome", "closeCode"); + } +} + ``` + + - [ ] **Step 2: Run the focused test and verify the failure** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-observability:test --tests 'io.backend.skeleton.websocket.observability.WebSocketObservationContextTest' + ``` + + Expected: FAIL because `WebSocketObservationContext` and its required policy contract do not exist yet. + + - [ ] **Step 3: Implement the smallest complete production contract** + + ```java + package io.backend.skeleton.websocket.observability; + +public record WebSocketObservationContext( + String endpointProfile, + String protocol, + String messageTypeCatalog, + String outcome, + int closeCode) { + public WebSocketObservationContext { + java.util.Objects.requireNonNull(endpointProfile, "endpointProfile"); + java.util.Objects.requireNonNull(protocol, "protocol"); + java.util.Objects.requireNonNull(messageTypeCatalog, "messageTypeCatalog"); + java.util.Objects.requireNonNull(outcome, "outcome"); + } +} + ``` + + Implement every listed production file with the exact public names, boundaries and invariants above. Keep raw transport sessions, credentials, payloads, database types and dynamic identifiers out of the public contract unless this task explicitly defines a bounded adapter type. + + - [ ] **Step 4: Run the focused test and the owning suite** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-observability:test --tests 'io.backend.skeleton.websocket.observability.WebSocketObservationContextTest' + ./gradlew websocketStableTest + ``` + + Expected: PASS for the focused test and the aggregate Stable suite. + + - [ ] **Step 5: Commit the independently reviewable change** + + ```bash + git add 'modules/websocket/websocket-observability/src/main/java/io/backend/skeleton/websocket/observability/WebSocketObservationContext.java' 'modules/websocket/websocket-observability/src/test/java/io/backend/skeleton/websocket/observability/WebSocketObservationContextTest.java' 'modules/websocket/websocket-observability/src/main/java/io/backend/skeleton/websocket/observability/WebSocketObservationConvention.java' 'modules/websocket/websocket-observability/src/main/java/io/backend/skeleton/websocket/observability/WebSocketCardinalityPolicy.java' 'modules/websocket/websocket-observability/src/main/java/io/backend/skeleton/websocket/observability/WebSocketLogRedactor.java' + git commit -m "feat: metric-trace-safe-logging" + ``` + +### Task 41: Admin Snapshot·Disconnect·Drain + + **Files:** + - Create: `modules/websocket/websocket-admin/src/main/java/io/backend/skeleton/websocket/admin/WebSocketAdminSnapshot.java` +- Create: `modules/websocket/websocket-admin/src/main/java/io/backend/skeleton/websocket/admin/WebSocketAdminService.java` +- Create: `modules/websocket/websocket-admin/src/main/java/io/backend/skeleton/websocket/admin/WebSocketAdminAudit.java` +- Test: `modules/websocket/websocket-admin/src/test/java/io/backend/skeleton/websocket/admin/WebSocketAdminSnapshotTest.java` + + **Interfaces:** + - Consumes: Session registry·buffer admission·observation state. + - Produces: safe connection summary와 audited disconnect/drain operations. + + **Implementation requirements:** + - payload·token·full filter를 노출하지 않는다. +- disconnect와 drain에는 actor·reason·audit record가 필요하다. +- Admin API가 raw transport session을 반환하지 않는다. + + - [ ] **Step 1: Write the failing test** + + ```java + package io.backend.skeleton.websocket.admin; + +class WebSocketAdminSnapshotTest { + @org.junit.jupiter.api.Test + void recordShapeIsStable() { + var names = java.util.Arrays.stream(WebSocketAdminSnapshot.class.getRecordComponents()) + .map(java.lang.reflect.RecordComponent::getName) + .toList(); + org.assertj.core.api.Assertions.assertThat(names) + .containsExactly("activeConnections", "drainingConnections", "slowConsumers", "bufferedBytes"); + } +} + ``` + + - [ ] **Step 2: Run the focused test and verify the failure** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-admin:test --tests 'io.backend.skeleton.websocket.admin.WebSocketAdminSnapshotTest' + ``` + + Expected: FAIL because `WebSocketAdminSnapshot` and its required policy contract do not exist yet. + + - [ ] **Step 3: Implement the smallest complete production contract** + + ```java + package io.backend.skeleton.websocket.admin; + +public record WebSocketAdminSnapshot( + long activeConnections, + long drainingConnections, + long slowConsumers, + long bufferedBytes) { + public WebSocketAdminSnapshot { + + } +} + ``` + + Implement every listed production file with the exact public names, boundaries and invariants above. Keep raw transport sessions, credentials, payloads, database types and dynamic identifiers out of the public contract unless this task explicitly defines a bounded adapter type. + + - [ ] **Step 4: Run the focused test and the owning suite** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-admin:test --tests 'io.backend.skeleton.websocket.admin.WebSocketAdminSnapshotTest' + ./gradlew websocketStableTest + ``` + + Expected: PASS for the focused test and the aggregate Stable suite. + + - [ ] **Step 5: Commit the independently reviewable change** + + ```bash + git add 'modules/websocket/websocket-admin/src/main/java/io/backend/skeleton/websocket/admin/WebSocketAdminSnapshot.java' 'modules/websocket/websocket-admin/src/test/java/io/backend/skeleton/websocket/admin/WebSocketAdminSnapshotTest.java' 'modules/websocket/websocket-admin/src/main/java/io/backend/skeleton/websocket/admin/WebSocketAdminService.java' 'modules/websocket/websocket-admin/src/main/java/io/backend/skeleton/websocket/admin/WebSocketAdminAudit.java' + git commit -m "feat: admin-snapshot-disconnect-drain" + ``` + +### Task 42: Spring Boot Properties와 Startup Validator + + **Files:** + - Create: `modules/websocket/websocket-core-api/src/main/java/io/backend/skeleton/websocket/config/WebSocketPlatformProperties.java` +- Create: `modules/websocket/websocket-core-api/src/main/java/io/backend/skeleton/websocket/config/WebSocketPlatformStartupValidator.java` +- Create: `modules/websocket/websocket-core-api/src/main/java/io/backend/skeleton/websocket/config/WebSocketEndpointProperties.java` +- Test: `modules/websocket/websocket-core-api/src/test/java/io/backend/skeleton/websocket/config/WebSocketPlatformPropertiesTest.java` + + **Interfaces:** + - Consumes: 모든 Stable profile catalog. + - Produces: typed configuration binding과 fail-fast invariant. + + **Implementation requirements:** + - MVC/WebFlux 동시 활성화를 거부한다. +- production no-subprotocol·wildcard credential Origin·plaintext를 거부한다. +- heartbeat·proxy timeout 순서와 모든 positive budget을 검증한다. + + - [ ] **Step 1: Write the failing test** + + ```java + package io.backend.skeleton.websocket.config; + +class WebSocketPlatformPropertiesTest { + @org.junit.jupiter.api.Test + void recordShapeIsStable() { + var names = java.util.Arrays.stream(WebSocketPlatformProperties.class.getRecordComponents()) + .map(java.lang.reflect.RecordComponent::getName) + .toList(); + org.assertj.core.api.Assertions.assertThat(names) + .containsExactly("stack", "nodeId", "endpointPaths", "globalBufferBytes"); + } +} + ``` + + - [ ] **Step 2: Run the focused test and verify the failure** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-core-api:test --tests 'io.backend.skeleton.websocket.config.WebSocketPlatformPropertiesTest' + ``` + + Expected: FAIL because `WebSocketPlatformProperties` and its required policy contract do not exist yet. + + - [ ] **Step 3: Implement the smallest complete production contract** + + ```java + package io.backend.skeleton.websocket.config; + +public record WebSocketPlatformProperties( + String stack, + String nodeId, + java.util.Map endpointPaths, + long globalBufferBytes) { + public WebSocketPlatformProperties { + java.util.Objects.requireNonNull(stack, "stack"); + java.util.Objects.requireNonNull(nodeId, "nodeId"); + java.util.Objects.requireNonNull(endpointPaths, "endpointPaths"); + } +} + ``` + + Implement every listed production file with the exact public names, boundaries and invariants above. Keep raw transport sessions, credentials, payloads, database types and dynamic identifiers out of the public contract unless this task explicitly defines a bounded adapter type. + + - [ ] **Step 4: Run the focused test and the owning suite** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-core-api:test --tests 'io.backend.skeleton.websocket.config.WebSocketPlatformPropertiesTest' + ./gradlew websocketStableTest + ``` + + Expected: PASS for the focused test and the aggregate Stable suite. + + - [ ] **Step 5: Commit the independently reviewable change** + + ```bash + git add 'modules/websocket/websocket-core-api/src/main/java/io/backend/skeleton/websocket/config/WebSocketPlatformProperties.java' 'modules/websocket/websocket-core-api/src/test/java/io/backend/skeleton/websocket/config/WebSocketPlatformPropertiesTest.java' 'modules/websocket/websocket-core-api/src/main/java/io/backend/skeleton/websocket/config/WebSocketPlatformStartupValidator.java' 'modules/websocket/websocket-core-api/src/main/java/io/backend/skeleton/websocket/config/WebSocketEndpointProperties.java' + git commit -m "feat: spring-boot-properties-startup-validator" + ``` + +### Task 43: MVC Starter Auto-configuration + + **Files:** + - Create: `modules/websocket/websocket-spring-boot-starter-mvc/src/main/java/io/backend/skeleton/websocket/starter/mvc/WebSocketMvcAutoConfigurationMarker.java` +- Create: `modules/websocket/websocket-spring-boot-starter-mvc/src/main/java/io/backend/skeleton/websocket/starter/mvc/WebSocketMvcAutoConfiguration.java` +- Create: `modules/websocket/websocket-spring-boot-starter-mvc/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` +- Test: `modules/websocket/websocket-spring-boot-starter-mvc/src/test/java/io/backend/skeleton/websocket/starter/mvc/WebSocketMvcAutoConfigurationMarkerTest.java` + + **Interfaces:** + - Consumes: Servlet runtime·properties·security·session·observability. + - Produces: Tomcat/Jetty MVC starter with no Advanced dependencies. + + **Implementation requirements:** + - Servlet stack 조건에서만 활성화한다. +- endpoint catalog를 startup에 freeze한다. +- WebFlux runtime bean이 존재하면 context를 실패시킨다. + + - [ ] **Step 1: Write the failing test** + + ```java + package io.backend.skeleton.websocket.starter.mvc; + +class WebSocketMvcAutoConfigurationMarkerTest { + @org.junit.jupiter.api.Test + void policyNameIsStable() { + org.assertj.core.api.Assertions.assertThat(WebSocketMvcAutoConfigurationMarker.policyName()) + .isEqualTo("websocket-mvc-auto-configuration"); + } +} + ``` + + - [ ] **Step 2: Run the focused test and verify the failure** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-spring-boot-starter-mvc:test --tests 'io.backend.skeleton.websocket.starter.mvc.WebSocketMvcAutoConfigurationMarkerTest' + ``` + + Expected: FAIL because `WebSocketMvcAutoConfigurationMarker` and its required policy contract do not exist yet. + + - [ ] **Step 3: Implement the smallest complete production contract** + + ```java + package io.backend.skeleton.websocket.starter.mvc; + +public final class WebSocketMvcAutoConfigurationMarker { + private WebSocketMvcAutoConfigurationMarker() {} + + public static String policyName() { + return "websocket-mvc-auto-configuration"; + } +} + ``` + + Implement every listed production file with the exact public names, boundaries and invariants above. Keep raw transport sessions, credentials, payloads, database types and dynamic identifiers out of the public contract unless this task explicitly defines a bounded adapter type. + + - [ ] **Step 4: Run the focused test and the owning suite** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-spring-boot-starter-mvc:test --tests 'io.backend.skeleton.websocket.starter.mvc.WebSocketMvcAutoConfigurationMarkerTest' + ./gradlew websocketStableTest + ``` + + Expected: PASS for the focused test and the aggregate Stable suite. + + - [ ] **Step 5: Commit the independently reviewable change** + + ```bash + git add 'modules/websocket/websocket-spring-boot-starter-mvc/src/main/java/io/backend/skeleton/websocket/starter/mvc/WebSocketMvcAutoConfigurationMarker.java' 'modules/websocket/websocket-spring-boot-starter-mvc/src/test/java/io/backend/skeleton/websocket/starter/mvc/WebSocketMvcAutoConfigurationMarkerTest.java' 'modules/websocket/websocket-spring-boot-starter-mvc/src/main/java/io/backend/skeleton/websocket/starter/mvc/WebSocketMvcAutoConfiguration.java' 'modules/websocket/websocket-spring-boot-starter-mvc/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports' + git commit -m "feat: mvc-starter-auto-configuration" + ``` + +### Task 44: WebFlux Starter Auto-configuration + + **Files:** + - Create: `modules/websocket/websocket-spring-boot-starter-webflux/src/main/java/io/backend/skeleton/websocket/starter/webflux/WebSocketWebFluxAutoConfigurationMarker.java` +- Create: `modules/websocket/websocket-spring-boot-starter-webflux/src/main/java/io/backend/skeleton/websocket/starter/webflux/WebSocketWebFluxAutoConfiguration.java` +- Create: `modules/websocket/websocket-spring-boot-starter-webflux/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` +- Test: `modules/websocket/websocket-spring-boot-starter-webflux/src/test/java/io/backend/skeleton/websocket/starter/webflux/WebSocketWebFluxAutoConfigurationMarkerTest.java` + + **Interfaces:** + - Consumes: Reactive runtime·properties·security·session·observability. + - Produces: Reactor Netty WebFlux starter with no Advanced dependencies. + + **Implementation requirements:** + - Reactive stack 조건에서만 활성화한다. +- Servlet runtime bean이 존재하면 context를 실패시킨다. +- blocking Application handler를 등록하지 못하게 한다. + + - [ ] **Step 1: Write the failing test** + + ```java + package io.backend.skeleton.websocket.starter.webflux; + +class WebSocketWebFluxAutoConfigurationMarkerTest { + @org.junit.jupiter.api.Test + void policyNameIsStable() { + org.assertj.core.api.Assertions.assertThat(WebSocketWebFluxAutoConfigurationMarker.policyName()) + .isEqualTo("websocket-webflux-auto-configuration"); + } +} + ``` + + - [ ] **Step 2: Run the focused test and verify the failure** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-spring-boot-starter-webflux:test --tests 'io.backend.skeleton.websocket.starter.webflux.WebSocketWebFluxAutoConfigurationMarkerTest' + ``` + + Expected: FAIL because `WebSocketWebFluxAutoConfigurationMarker` and its required policy contract do not exist yet. + + - [ ] **Step 3: Implement the smallest complete production contract** + + ```java + package io.backend.skeleton.websocket.starter.webflux; + +public final class WebSocketWebFluxAutoConfigurationMarker { + private WebSocketWebFluxAutoConfigurationMarker() {} + + public static String policyName() { + return "websocket-webflux-auto-configuration"; + } +} + ``` + + Implement every listed production file with the exact public names, boundaries and invariants above. Keep raw transport sessions, credentials, payloads, database types and dynamic identifiers out of the public contract unless this task explicitly defines a bounded adapter type. + + - [ ] **Step 4: Run the focused test and the owning suite** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-spring-boot-starter-webflux:test --tests 'io.backend.skeleton.websocket.starter.webflux.WebSocketWebFluxAutoConfigurationMarkerTest' + ./gradlew websocketStableTest + ``` + + Expected: PASS for the focused test and the aggregate Stable suite. + + - [ ] **Step 5: Commit the independently reviewable change** + + ```bash + git add 'modules/websocket/websocket-spring-boot-starter-webflux/src/main/java/io/backend/skeleton/websocket/starter/webflux/WebSocketWebFluxAutoConfigurationMarker.java' 'modules/websocket/websocket-spring-boot-starter-webflux/src/test/java/io/backend/skeleton/websocket/starter/webflux/WebSocketWebFluxAutoConfigurationMarkerTest.java' 'modules/websocket/websocket-spring-boot-starter-webflux/src/main/java/io/backend/skeleton/websocket/starter/webflux/WebSocketWebFluxAutoConfiguration.java' 'modules/websocket/websocket-spring-boot-starter-webflux/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports' + git commit -m "feat: webflux-starter-auto-configuration" + ``` + +### Task 45: MVC·WebFlux Stack 상호 배타성 + + **Files:** + - Create: `modules/websocket/websocket-testkit-core/src/main/java/io/backend/skeleton/websocket/architecture/WebSocketStackExclusivity.java` +- Create: `modules/websocket/websocket-testkit-core/src/test/java/io/backend/skeleton/websocket/architecture/WebSocketStackExclusivityApplicationContextTest.java` +- Test: `modules/websocket/websocket-testkit-core/src/test/java/io/backend/skeleton/websocket/architecture/WebSocketStackExclusivityTest.java` + + **Interfaces:** + - Consumes: 두 Starter auto-configuration. + - Produces: 동시 starter 또는 혼합 runtime의 deterministic startup failure. + + **Implementation requirements:** + - dependency만 함께 존재해도 잘못된 stack이 조용히 선택되지 않도록 한다. +- 오류 메시지에 선택 가능한 두 profile을 명시한다. +- test는 두 ApplicationContext 조합을 모두 실행한다. + + - [ ] **Step 1: Write the failing test** + + ```java + package io.backend.skeleton.websocket.architecture; + +class WebSocketStackExclusivityTest { + @org.junit.jupiter.api.Test + void policyNameIsStable() { + org.assertj.core.api.Assertions.assertThat(WebSocketStackExclusivity.policyName()) + .isEqualTo("mvc-webflux-mutually-exclusive"); + } +} + ``` + + - [ ] **Step 2: Run the focused test and verify the failure** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-testkit-core:test --tests 'io.backend.skeleton.websocket.architecture.WebSocketStackExclusivityTest' + ``` + + Expected: FAIL because `WebSocketStackExclusivity` and its required policy contract do not exist yet. + + - [ ] **Step 3: Implement the smallest complete production contract** + + ```java + package io.backend.skeleton.websocket.architecture; + +public final class WebSocketStackExclusivity { + private WebSocketStackExclusivity() {} + + public static String policyName() { + return "mvc-webflux-mutually-exclusive"; + } +} + ``` + + Implement every listed production file with the exact public names, boundaries and invariants above. Keep raw transport sessions, credentials, payloads, database types and dynamic identifiers out of the public contract unless this task explicitly defines a bounded adapter type. + + - [ ] **Step 4: Run the focused test and the owning suite** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-testkit-core:test --tests 'io.backend.skeleton.websocket.architecture.WebSocketStackExclusivityTest' + ./gradlew websocketStableTest + ``` + + Expected: PASS for the focused test and the aggregate Stable suite. + + - [ ] **Step 5: Commit the independently reviewable change** + + ```bash + git add 'modules/websocket/websocket-testkit-core/src/main/java/io/backend/skeleton/websocket/architecture/WebSocketStackExclusivity.java' 'modules/websocket/websocket-testkit-core/src/test/java/io/backend/skeleton/websocket/architecture/WebSocketStackExclusivityTest.java' 'modules/websocket/websocket-testkit-core/src/test/java/io/backend/skeleton/websocket/architecture/WebSocketStackExclusivityApplicationContextTest.java' + git commit -m "feat: mvc-webflux-stack" + ``` + +### Task 46: Nginx TLS·Upgrade·Forwarded Header Contract + + **Files:** + - Create: `modules/websocket/websocket-testkit-proxy/src/main/java/io/backend/skeleton/websocket/proxy/WebSocketNginxProxyProfile.java` +- Create: `modules/websocket/websocket-testkit-proxy/src/test/resources/nginx/websocket.conf` +- Create: `modules/websocket/websocket-testkit-proxy/src/test/java/io/backend/skeleton/websocket/proxy/NginxWebSocketProxyContractTest.java` +- Test: `modules/websocket/websocket-testkit-proxy/src/test/java/io/backend/skeleton/websocket/proxy/WebSocketNginxProxyProfileTest.java` + + **Interfaces:** + - Consumes: `web` trusted proxy policy와 Stable runtime. + - Produces: `/ws`·`/dev-ws` Upgrade/TLS/timeout contract. + + **Implementation requirements:** + - incoming forwarded headers를 sanitize한다. +- Upgrade와 Connection을 upstream에 명시적으로 전달한다. +- proxyReadTimeout은 heartbeatTimeout보다 길다. +- ticket·token을 access log에서 마스킹한다. + + - [ ] **Step 1: Write the failing test** + + ```java + package io.backend.skeleton.websocket.proxy; + +class WebSocketNginxProxyProfileTest { + @org.junit.jupiter.api.Test + void recordShapeIsStable() { + var names = java.util.Arrays.stream(WebSocketNginxProxyProfile.class.getRecordComponents()) + .map(java.lang.reflect.RecordComponent::getName) + .toList(); + org.assertj.core.api.Assertions.assertThat(names) + .containsExactly("publicPath", "upstreamPath", "proxyReadTimeout", "tlsRequired"); + } +} + ``` + + - [ ] **Step 2: Run the focused test and verify the failure** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-testkit-proxy:test --tests 'io.backend.skeleton.websocket.proxy.WebSocketNginxProxyProfileTest' + ``` + + Expected: FAIL because `WebSocketNginxProxyProfile` and its required policy contract do not exist yet. + + - [ ] **Step 3: Implement the smallest complete production contract** + + ```java + package io.backend.skeleton.websocket.proxy; + +public record WebSocketNginxProxyProfile( + String publicPath, + String upstreamPath, + java.time.Duration proxyReadTimeout, + boolean tlsRequired) { + public WebSocketNginxProxyProfile { + java.util.Objects.requireNonNull(publicPath, "publicPath"); + java.util.Objects.requireNonNull(upstreamPath, "upstreamPath"); + java.util.Objects.requireNonNull(proxyReadTimeout, "proxyReadTimeout"); + } +} + ``` + + Implement every listed production file with the exact public names, boundaries and invariants above. Keep raw transport sessions, credentials, payloads, database types and dynamic identifiers out of the public contract unless this task explicitly defines a bounded adapter type. + + - [ ] **Step 4: Run the focused test and the owning suite** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-testkit-proxy:test --tests 'io.backend.skeleton.websocket.proxy.WebSocketNginxProxyProfileTest' + ./gradlew websocketStableTest + ``` + + Expected: PASS for the focused test and the aggregate Stable suite. + + - [ ] **Step 5: Commit the independently reviewable change** + + ```bash + git add 'modules/websocket/websocket-testkit-proxy/src/main/java/io/backend/skeleton/websocket/proxy/WebSocketNginxProxyProfile.java' 'modules/websocket/websocket-testkit-proxy/src/test/java/io/backend/skeleton/websocket/proxy/WebSocketNginxProxyProfileTest.java' 'modules/websocket/websocket-testkit-proxy/src/test/resources/nginx/websocket.conf' 'modules/websocket/websocket-testkit-proxy/src/test/java/io/backend/skeleton/websocket/proxy/NginxWebSocketProxyContractTest.java' + git commit -m "feat: nginx-tls-upgrade-forwarded-header-contract" + ``` + +### Task 47: Browser Protocol Test Client + + **Files:** + - Create: `modules/websocket/websocket-testkit-browser/src/main/java/io/backend/skeleton/websocket/browser/WebSocketBrowserClientProfile.java` +- Create: `modules/websocket/websocket-testkit-browser/src/test/resources/browser/websocket-test-client.js` +- Create: `modules/websocket/websocket-testkit-browser/src/test/java/io/backend/skeleton/websocket/browser/WebSocketBrowserProtocolFixtureTest.java` +- Test: `modules/websocket/websocket-testkit-browser/src/test/java/io/backend/skeleton/websocket/browser/WebSocketBrowserClientProfileTest.java` + + **Interfaces:** + - Consumes: Stable subprotocol·envelope·close catalog. + - Produces: bounded `bufferedAmount`와 reconnect jitter를 구현한 browser fixture. + + **Implementation requirements:** + - offline command queue는 bounded다. +- expiresAt·idempotency가 없는 mutation을 offline queue에 넣지 않는다. +- bufferedAmount는 server receive ACK로 해석하지 않는다. + + - [ ] **Step 1: Write the failing test** + + ```java + package io.backend.skeleton.websocket.browser; + +class WebSocketBrowserClientProfileTest { + @org.junit.jupiter.api.Test + void recordShapeIsStable() { + var names = java.util.Arrays.stream(WebSocketBrowserClientProfile.class.getRecordComponents()) + .map(java.lang.reflect.RecordComponent::getName) + .toList(); + org.assertj.core.api.Assertions.assertThat(names) + .containsExactly("browser", "maxBufferedAmount", "maxPendingCommands", "reconnectBaseDelay"); + } +} + ``` + + - [ ] **Step 2: Run the focused test and verify the failure** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-testkit-browser:test --tests 'io.backend.skeleton.websocket.browser.WebSocketBrowserClientProfileTest' + ``` + + Expected: FAIL because `WebSocketBrowserClientProfile` and its required policy contract do not exist yet. + + - [ ] **Step 3: Implement the smallest complete production contract** + + ```java + package io.backend.skeleton.websocket.browser; + +public record WebSocketBrowserClientProfile( + String browser, + long maxBufferedAmount, + int maxPendingCommands, + java.time.Duration reconnectBaseDelay) { + public WebSocketBrowserClientProfile { + java.util.Objects.requireNonNull(browser, "browser"); + java.util.Objects.requireNonNull(reconnectBaseDelay, "reconnectBaseDelay"); + } +} + ``` + + Implement every listed production file with the exact public names, boundaries and invariants above. Keep raw transport sessions, credentials, payloads, database types and dynamic identifiers out of the public contract unless this task explicitly defines a bounded adapter type. + + - [ ] **Step 4: Run the focused test and the owning suite** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-testkit-browser:test --tests 'io.backend.skeleton.websocket.browser.WebSocketBrowserClientProfileTest' + ./gradlew websocketStableTest + ``` + + Expected: PASS for the focused test and the aggregate Stable suite. + + - [ ] **Step 5: Commit the independently reviewable change** + + ```bash + git add 'modules/websocket/websocket-testkit-browser/src/main/java/io/backend/skeleton/websocket/browser/WebSocketBrowserClientProfile.java' 'modules/websocket/websocket-testkit-browser/src/test/java/io/backend/skeleton/websocket/browser/WebSocketBrowserClientProfileTest.java' 'modules/websocket/websocket-testkit-browser/src/test/resources/browser/websocket-test-client.js' 'modules/websocket/websocket-testkit-browser/src/test/java/io/backend/skeleton/websocket/browser/WebSocketBrowserProtocolFixtureTest.java' + git commit -m "feat: browser-protocol-test-client" + ``` + +### Task 48: Chromium·Firefox·WebKit Browser Matrix + + **Files:** + - Create: `modules/websocket/websocket-testkit-browser/src/main/java/io/backend/skeleton/websocket/browser/WebSocketBrowserMatrix.java` +- Create: `modules/websocket/websocket-testkit-browser/src/test/java/io/backend/skeleton/websocket/browser/WebSocketBrowserMatrixTest.java` +- Test: `modules/websocket/websocket-testkit-browser/src/test/java/io/backend/skeleton/websocket/browser/WebSocketBrowserMatrixTest.java` + + **Interfaces:** + - Consumes: Browser fixture와 Nginx proxy test environment. + - Produces: foreground·background·sleep·offline·navigation browser evidence. + + **Implementation requirements:** + - Chromium·Firefox·WebKit을 모두 포함한다. +- browser close와 page navigation에서 server registry cleanup을 검증한다. +- background/sleep 후 heartbeat·max age 동작을 검증한다. + + - [ ] **Step 1: Write the failing test** + + ```java + package io.backend.skeleton.websocket.browser; + +class WebSocketBrowserMatrixTest { + @org.junit.jupiter.api.Test + void recordShapeIsStable() { + var names = java.util.Arrays.stream(WebSocketBrowserMatrix.class.getRecordComponents()) + .map(java.lang.reflect.RecordComponent::getName) + .toList(); + org.assertj.core.api.Assertions.assertThat(names) + .containsExactly("browsers", "lifecycleScenarios"); + } +} + ``` + + - [ ] **Step 2: Run the focused test and verify the failure** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-testkit-browser:test --tests 'io.backend.skeleton.websocket.browser.WebSocketBrowserMatrixTest' + ``` + + Expected: FAIL because `WebSocketBrowserMatrix` and its required policy contract do not exist yet. + + - [ ] **Step 3: Implement the smallest complete production contract** + + ```java + package io.backend.skeleton.websocket.browser; + +public record WebSocketBrowserMatrix( + java.util.Set browsers, + java.util.Set lifecycleScenarios) { + public WebSocketBrowserMatrix { + java.util.Objects.requireNonNull(browsers, "browsers"); + java.util.Objects.requireNonNull(lifecycleScenarios, "lifecycleScenarios"); + } +} + ``` + + Implement every listed production file with the exact public names, boundaries and invariants above. Keep raw transport sessions, credentials, payloads, database types and dynamic identifiers out of the public contract unless this task explicitly defines a bounded adapter type. + + - [ ] **Step 4: Run the focused test and the owning suite** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-testkit-browser:test --tests 'io.backend.skeleton.websocket.browser.WebSocketBrowserMatrixTest' + ./gradlew websocketStableTest + ``` + + Expected: PASS for the focused test and the aggregate Stable suite. + + - [ ] **Step 5: Commit the independently reviewable change** + + ```bash + git add 'modules/websocket/websocket-testkit-browser/src/main/java/io/backend/skeleton/websocket/browser/WebSocketBrowserMatrix.java' 'modules/websocket/websocket-testkit-browser/src/test/java/io/backend/skeleton/websocket/browser/WebSocketBrowserMatrixTest.java' 'modules/websocket/websocket-testkit-browser/src/test/java/io/backend/skeleton/websocket/browser/WebSocketBrowserMatrixTest.java' + git commit -m "feat: chromium-firefox-webkit-browser-matrix" + ``` + +### Task 49: Commit 후 Response Loss Fault Injection + + **Files:** + - Create: `modules/websocket/websocket-testkit-fault/src/main/java/io/backend/skeleton/websocket/fault/WebSocketResponseLossPoint.java` +- Create: `modules/websocket/websocket-testkit-fault/src/main/java/io/backend/skeleton/websocket/fault/WebSocketFaultInjector.java` +- Create: `modules/websocket/websocket-testkit-fault/src/test/java/io/backend/skeleton/websocket/fault/WebSocketCommitResponseLossContractTest.java` +- Test: `modules/websocket/websocket-testkit-fault/src/test/java/io/backend/skeleton/websocket/fault/WebSocketResponseLossPointTest.java` + + **Interfaces:** + - Consumes: Application handler·result ledger·serialized writer. + - Produces: commit 후 socket reset과 exact replay regression suite. + + **Implementation requirements:** + - commit 후 reset에서 client는 completion unknown을 관측한다. +- 동일 idempotency key 재전송은 기존 result를 반환한다. +- business side effect count는 1이다. + + - [ ] **Step 1: Write the failing test** + + ```java + package io.backend.skeleton.websocket.fault; + +class WebSocketResponseLossPointTest { + @org.junit.jupiter.api.Test + void valuesAreStable() { + org.assertj.core.api.Assertions.assertThat(WebSocketResponseLossPoint.values()) + .extracting(java.lang.Enum::name) + .containsExactly("BEFORE_APPLICATION_START", "AFTER_APPLICATION_START", "AFTER_APPLICATION_COMMIT_BEFORE_QUEUE", "AFTER_QUEUE_BEFORE_WRITE", "AFTER_WRITE_START", "AFTER_LOCAL_WRITE"); + } +} + ``` + + - [ ] **Step 2: Run the focused test and verify the failure** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-testkit-fault:test --tests 'io.backend.skeleton.websocket.fault.WebSocketResponseLossPointTest' + ``` + + Expected: FAIL because `WebSocketResponseLossPoint` and its required policy contract do not exist yet. + + - [ ] **Step 3: Implement the smallest complete production contract** + + ```java + package io.backend.skeleton.websocket.fault; + +public enum WebSocketResponseLossPoint { + BEFORE_APPLICATION_START, + AFTER_APPLICATION_START, + AFTER_APPLICATION_COMMIT_BEFORE_QUEUE, + AFTER_QUEUE_BEFORE_WRITE, + AFTER_WRITE_START, + AFTER_LOCAL_WRITE +} + ``` + + Implement every listed production file with the exact public names, boundaries and invariants above. Keep raw transport sessions, credentials, payloads, database types and dynamic identifiers out of the public contract unless this task explicitly defines a bounded adapter type. + + - [ ] **Step 4: Run the focused test and the owning suite** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-testkit-fault:test --tests 'io.backend.skeleton.websocket.fault.WebSocketResponseLossPointTest' + ./gradlew websocketStableTest + ``` + + Expected: PASS for the focused test and the aggregate Stable suite. + + - [ ] **Step 5: Commit the independently reviewable change** + + ```bash + git add 'modules/websocket/websocket-testkit-fault/src/main/java/io/backend/skeleton/websocket/fault/WebSocketResponseLossPoint.java' 'modules/websocket/websocket-testkit-fault/src/test/java/io/backend/skeleton/websocket/fault/WebSocketResponseLossPointTest.java' 'modules/websocket/websocket-testkit-fault/src/main/java/io/backend/skeleton/websocket/fault/WebSocketFaultInjector.java' 'modules/websocket/websocket-testkit-fault/src/test/java/io/backend/skeleton/websocket/fault/WebSocketCommitResponseLossContractTest.java' + git commit -m "feat: commit-response-loss-fault-injection" + ``` + +### Task 50: Slow Consumer·Global Buffer Performance Gate + + **Files:** + - Create: `modules/websocket/websocket-testkit-fault/src/main/java/io/backend/skeleton/websocket/performance/WebSocketSlowConsumerScenario.java` +- Create: `modules/websocket/websocket-testkit-fault/src/test/java/io/backend/skeleton/websocket/performance/WebSocketSlowConsumerPerformanceTest.java` +- Test: `modules/websocket/websocket-testkit-fault/src/test/java/io/backend/skeleton/websocket/performance/WebSocketSlowConsumerScenarioTest.java` + + **Interfaces:** + - Consumes: Queue·writer·global admission과 실제 runtimes. + - Produces: slow 1%·10%·50% 시나리오의 memory·latency evidence. + + **Implementation requirements:** + - global buffer hard limit를 초과하지 않는다. +- critical message silent drop은 0이다. +- fast-client p99와 GC·thread·direct-memory를 기록한다. + + - [ ] **Step 1: Write the failing test** + + ```java + package io.backend.skeleton.websocket.performance; + +class WebSocketSlowConsumerScenarioTest { + @org.junit.jupiter.api.Test + void recordShapeIsStable() { + var names = java.util.Arrays.stream(WebSocketSlowConsumerScenario.class.getRecordComponents()) + .map(java.lang.reflect.RecordComponent::getName) + .toList(); + org.assertj.core.api.Assertions.assertThat(names) + .containsExactly("connectionCount", "slowConsumerPercent", "globalBufferLimitBytes", "p99Budget"); + } +} + ``` + + - [ ] **Step 2: Run the focused test and verify the failure** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-testkit-fault:test --tests 'io.backend.skeleton.websocket.performance.WebSocketSlowConsumerScenarioTest' + ``` + + Expected: FAIL because `WebSocketSlowConsumerScenario` and its required policy contract do not exist yet. + + - [ ] **Step 3: Implement the smallest complete production contract** + + ```java + package io.backend.skeleton.websocket.performance; + +public record WebSocketSlowConsumerScenario( + int connectionCount, + int slowConsumerPercent, + long globalBufferLimitBytes, + java.time.Duration p99Budget) { + public WebSocketSlowConsumerScenario { + java.util.Objects.requireNonNull(p99Budget, "p99Budget"); + } +} + ``` + + Implement every listed production file with the exact public names, boundaries and invariants above. Keep raw transport sessions, credentials, payloads, database types and dynamic identifiers out of the public contract unless this task explicitly defines a bounded adapter type. + + - [ ] **Step 4: Run the focused test and the owning suite** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-testkit-fault:test --tests 'io.backend.skeleton.websocket.performance.WebSocketSlowConsumerScenarioTest' + ./gradlew websocketStableTest + ``` + + Expected: PASS for the focused test and the aggregate Stable suite. + + - [ ] **Step 5: Commit the independently reviewable change** + + ```bash + git add 'modules/websocket/websocket-testkit-fault/src/main/java/io/backend/skeleton/websocket/performance/WebSocketSlowConsumerScenario.java' 'modules/websocket/websocket-testkit-fault/src/test/java/io/backend/skeleton/websocket/performance/WebSocketSlowConsumerScenarioTest.java' 'modules/websocket/websocket-testkit-fault/src/test/java/io/backend/skeleton/websocket/performance/WebSocketSlowConsumerPerformanceTest.java' + git commit -m "feat: slow-consumer-global-buffer-performance-gate" + ``` + +### Task 51: Security Abuse Contract + + **Files:** + - Create: `modules/websocket/websocket-testkit-fault/src/main/java/io/backend/skeleton/websocket/security/WebSocketSecurityAbuseCatalog.java` +- Create: `modules/websocket/websocket-testkit-fault/src/test/java/io/backend/skeleton/websocket/security/WebSocketSecurityAbuseContractTest.java` +- Test: `modules/websocket/websocket-testkit-fault/src/test/java/io/backend/skeleton/websocket/security/WebSocketSecurityAbuseCatalogTest.java` + + **Interfaces:** + - Consumes: Handshake·Origin·ticket·message authorization·budget. + - Produces: cross-site hijack·ticket replay·oversize·rate abuse regression suite. + + **Implementation requirements:** + - Origin wildcard/credential 조합을 거부한다. +- ticket replay와 actor/tenant spoof를 거부한다. +- deep JSON·fragment flood·reconnect storm을 bounded response로 처리한다. + + - [ ] **Step 1: Write the failing test** + + ```java + package io.backend.skeleton.websocket.security; + +class WebSocketSecurityAbuseCatalogTest { + @org.junit.jupiter.api.Test + void recordShapeIsStable() { + var names = java.util.Arrays.stream(WebSocketSecurityAbuseCatalog.class.getRecordComponents()) + .map(java.lang.reflect.RecordComponent::getName) + .toList(); + org.assertj.core.api.Assertions.assertThat(names) + .containsExactly("scenarios"); + } +} + ``` + + - [ ] **Step 2: Run the focused test and verify the failure** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-testkit-fault:test --tests 'io.backend.skeleton.websocket.security.WebSocketSecurityAbuseCatalogTest' + ``` + + Expected: FAIL because `WebSocketSecurityAbuseCatalog` and its required policy contract do not exist yet. + + - [ ] **Step 3: Implement the smallest complete production contract** + + ```java + package io.backend.skeleton.websocket.security; + +public record WebSocketSecurityAbuseCatalog( + java.util.Set scenarios) { + public WebSocketSecurityAbuseCatalog { + java.util.Objects.requireNonNull(scenarios, "scenarios"); + } +} + ``` + + Implement every listed production file with the exact public names, boundaries and invariants above. Keep raw transport sessions, credentials, payloads, database types and dynamic identifiers out of the public contract unless this task explicitly defines a bounded adapter type. + + - [ ] **Step 4: Run the focused test and the owning suite** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-testkit-fault:test --tests 'io.backend.skeleton.websocket.security.WebSocketSecurityAbuseCatalogTest' + ./gradlew websocketStableTest + ``` + + Expected: PASS for the focused test and the aggregate Stable suite. + + - [ ] **Step 5: Commit the independently reviewable change** + + ```bash + git add 'modules/websocket/websocket-testkit-fault/src/main/java/io/backend/skeleton/websocket/security/WebSocketSecurityAbuseCatalog.java' 'modules/websocket/websocket-testkit-fault/src/test/java/io/backend/skeleton/websocket/security/WebSocketSecurityAbuseCatalogTest.java' 'modules/websocket/websocket-testkit-fault/src/test/java/io/backend/skeleton/websocket/security/WebSocketSecurityAbuseContractTest.java' + git commit -m "feat: security-abuse-contract" + ``` + +### Task 52: Graceful Shutdown·Rolling Restart Gate + + **Files:** + - Create: `modules/websocket/websocket-testkit-fault/src/main/java/io/backend/skeleton/websocket/fault/WebSocketRollingRestartScenario.java` +- Create: `modules/websocket/websocket-testkit-fault/src/test/java/io/backend/skeleton/websocket/fault/WebSocketRollingRestartContractTest.java` +- Test: `modules/websocket/websocket-testkit-fault/src/test/java/io/backend/skeleton/websocket/fault/WebSocketRollingRestartScenarioTest.java` + + **Interfaces:** + - Consumes: Drain coordinator·Nginx·browser fixture. + - Produces: readiness off·new handshake reject·1012 close·client reconnect evidence. + + **Implementation requirements:** + - draining node에 신규 connection을 만들지 않는다. +- bounded queue를 deadline 내 drain한다. +- client는 jittered reconnect를 수행하고 duplicate command를 만들지 않는다. + + - [ ] **Step 1: Write the failing test** + + ```java + package io.backend.skeleton.websocket.fault; + +class WebSocketRollingRestartScenarioTest { + @org.junit.jupiter.api.Test + void recordShapeIsStable() { + var names = java.util.Arrays.stream(WebSocketRollingRestartScenario.class.getRecordComponents()) + .map(java.lang.reflect.RecordComponent::getName) + .toList(); + org.assertj.core.api.Assertions.assertThat(names) + .containsExactly("connections", "drainDeadline", "expectedCloseCode"); + } +} + ``` + + - [ ] **Step 2: Run the focused test and verify the failure** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-testkit-fault:test --tests 'io.backend.skeleton.websocket.fault.WebSocketRollingRestartScenarioTest' + ``` + + Expected: FAIL because `WebSocketRollingRestartScenario` and its required policy contract do not exist yet. + + - [ ] **Step 3: Implement the smallest complete production contract** + + ```java + package io.backend.skeleton.websocket.fault; + +public record WebSocketRollingRestartScenario( + int connections, + java.time.Duration drainDeadline, + int expectedCloseCode) { + public WebSocketRollingRestartScenario { + java.util.Objects.requireNonNull(drainDeadline, "drainDeadline"); + } +} + ``` + + Implement every listed production file with the exact public names, boundaries and invariants above. Keep raw transport sessions, credentials, payloads, database types and dynamic identifiers out of the public contract unless this task explicitly defines a bounded adapter type. + + - [ ] **Step 4: Run the focused test and the owning suite** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-testkit-fault:test --tests 'io.backend.skeleton.websocket.fault.WebSocketRollingRestartScenarioTest' + ./gradlew websocketStableTest + ``` + + Expected: PASS for the focused test and the aggregate Stable suite. + + - [ ] **Step 5: Commit the independently reviewable change** + + ```bash + git add 'modules/websocket/websocket-testkit-fault/src/main/java/io/backend/skeleton/websocket/fault/WebSocketRollingRestartScenario.java' 'modules/websocket/websocket-testkit-fault/src/test/java/io/backend/skeleton/websocket/fault/WebSocketRollingRestartScenarioTest.java' 'modules/websocket/websocket-testkit-fault/src/test/java/io/backend/skeleton/websocket/fault/WebSocketRollingRestartContractTest.java' + git commit -m "feat: graceful-shutdown-rolling-restart-gate" + ``` + +### Task 53: Stable Release Gate·Runbook·ADR + + **Files:** + - Create: `modules/websocket/websocket-testkit-core/src/main/java/io/backend/skeleton/websocket/release/WebSocketStableReleaseGate.java` +- Create: `docs/superpowers/adr/ADR-WS-001-connection-runtime-and-protocol-adapter.md` +- Create: `docs/superpowers/runbooks/websocket-handshake-failures.md` +- Create: `docs/superpowers/runbooks/websocket-slow-consumer.md` +- Create: `docs/superpowers/runbooks/websocket-rolling-restart.md` +- Create: `docs/superpowers/runbooks/websocket-completion-unknown.md` +- Create: `docs/superpowers/support/websocket-stable-support-matrix.md` +- Test: `modules/websocket/websocket-testkit-core/src/test/java/io/backend/skeleton/websocket/release/WebSocketStableReleaseGateTest.java` + + **Interfaces:** + - Consumes: Stable Task 1–52의 전체 evidence. + - Produces: Stable promotion checklist와 운영 runbooks. + + **Implementation requirements:** + - 실제 browser·Nginx·Tomcat·Jetty·Reactor Netty를 필수 gate로 둔다. +- commit-response-loss와 slow-consumer test를 release 전에 실행한다. +- Advanced dependency가 Stable artifact에 없음을 검사한다. +- 지원·비지원 보장을 문서화한다. + + - [ ] **Step 1: Write the failing test** + + ```java + package io.backend.skeleton.websocket.release; + +class WebSocketStableReleaseGateTest { + @org.junit.jupiter.api.Test + void recordShapeIsStable() { + var names = java.util.Arrays.stream(WebSocketStableReleaseGate.class.getRecordComponents()) + .map(java.lang.reflect.RecordComponent::getName) + .toList(); + org.assertj.core.api.Assertions.assertThat(names) + .containsExactly("requiredSuites", "advancedDependencyExcluded", "actualBrowserRequired", "actualProxyRequired"); + } +} + ``` + + - [ ] **Step 2: Run the focused test and verify the failure** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-testkit-core:test --tests 'io.backend.skeleton.websocket.release.WebSocketStableReleaseGateTest' + ``` + + Expected: FAIL because `WebSocketStableReleaseGate` and its required policy contract do not exist yet. + + - [ ] **Step 3: Implement the smallest complete production contract** + + ```java + package io.backend.skeleton.websocket.release; + +public record WebSocketStableReleaseGate( + java.util.Set requiredSuites, + boolean advancedDependencyExcluded, + boolean actualBrowserRequired, + boolean actualProxyRequired) { + public WebSocketStableReleaseGate { + java.util.Objects.requireNonNull(requiredSuites, "requiredSuites"); + } +} + ``` + + Implement every listed production file with the exact public names, boundaries and invariants above. Keep raw transport sessions, credentials, payloads, database types and dynamic identifiers out of the public contract unless this task explicitly defines a bounded adapter type. + + - [ ] **Step 4: Run the focused test and the owning suite** + + Run: + + ```bash + ./gradlew :modules:websocket:websocket-testkit-core:test --tests 'io.backend.skeleton.websocket.release.WebSocketStableReleaseGateTest' + ./gradlew websocketStableTest + ``` + + Expected: PASS for the focused test and the aggregate Stable suite. + + - [ ] **Step 5: Commit the independently reviewable change** + + ```bash + git add 'modules/websocket/websocket-testkit-core/src/main/java/io/backend/skeleton/websocket/release/WebSocketStableReleaseGate.java' 'modules/websocket/websocket-testkit-core/src/test/java/io/backend/skeleton/websocket/release/WebSocketStableReleaseGateTest.java' 'docs/superpowers/adr/ADR-WS-001-connection-runtime-and-protocol-adapter.md' 'docs/superpowers/runbooks/websocket-handshake-failures.md' 'docs/superpowers/runbooks/websocket-slow-consumer.md' 'docs/superpowers/runbooks/websocket-rolling-restart.md' 'docs/superpowers/runbooks/websocket-completion-unknown.md' 'docs/superpowers/support/websocket-stable-support-matrix.md' + git commit -m "feat: stable-release-gate-runbook-adr" + ``` diff --git a/docs/websocket-superpowers-package/docs/superpowers/specs/2026-08-14-websocket-realtime-connection-platform-design.md b/docs/websocket-superpowers-package/docs/superpowers/specs/2026-08-14-websocket-realtime-connection-platform-design.md new file mode 100644 index 00000000..09edbef4 --- /dev/null +++ b/docs/websocket-superpowers-package/docs/superpowers/specs/2026-08-14-websocket-realtime-connection-platform-design.md @@ -0,0 +1,2810 @@ +# WebSocket 실시간 양방향 연결 실행 플랫폼 설계서 + +- **문서 상태:** 구현 기준선 확정 +- **기준일:** 2026-08-14 +- **대상 저장소:** Java/Spring Backend Skeleton +- **Root package:** `io.backend.skeleton.websocket` +- **Stable module root:** `modules/websocket` +- **Advanced module root:** `modules/websocket-advanced` +- **요구사항 원본:** `WebSocket 실시간 양방향 연결 실행 플랫폼 심층 리서치` + +## 0. 확정 경계 요약 + +- 이 모듈은 Echo Handler나 `@MessageMapping` 편의 모음이 아니라 **Connection Runtime·Typed Protocol·실행 증거·Backpressure·Lifecycle·Recovery orchestration**을 소유한다. +- Stable 기준선은 **Java 21 + Spring Boot 4.1 BOM**이다. +- Stable 기본 프로토콜은 `hyeonworks.realtime.v1.json`과 UTF-8 JSON Typed Envelope다. +- Stable Runtime은 Servlet/Tomcat을 기본으로 하고 Jetty 호환 Lane과 WebFlux/Reactor Netty 선택 Stable Profile을 제공한다. +- MVC와 WebFlux Starter는 상호 배타적이다. +- WebSocket은 Upgrade 이후를 소유한다. Upgrade 전 HTTP 오류·Forwarded Header·인증 진입은 기존 `web` 계약을 사용한다. +- WebSocket은 Durable Messaging이 아니다. ACK·Replay·DLQ·Offset 이력은 `messaging`이 소유한다. +- GraphQL Subscription 의미론은 `graphql`이 소유하며 WebSocket은 transport bridge만 제공한다. +- 대형 Binary와 파일 업로드·다운로드는 `fileserver`·`objectstorage`가 소유한다. +- `sendMessage()` 반환을 Client 수신 또는 Client 적용 증거로 승격하지 않는다. +- 실행 증거는 **Inbound Evidence + Outbound Evidence + Connection Evidence**의 세 축으로 기록한다. +- 상태 변경 Command의 강한 완료 증거는 WebSocket write가 아니라 Application transaction과 결합된 Idempotency·Result Ledger다. +- Session Sequence와 업무 Idempotency를 동일시하지 않는다. +- Session별 Outbound write는 직렬화하고 Queue는 message count와 bytes 모두 bounded다. +- Slow Consumer의 기본 처리는 무한 Buffer가 아니라 메시지 분류에 따른 Disconnect·Drop·Coalesce다. +- Stable 기본은 Raw Typed JSON이며 STOMP, Broker Relay, Resume, Cluster fan-out, Binary Codec, Compression, SockJS, HTTP/2·3은 Advanced 계획으로 분리한다. +- Stable Release Gate는 실제 Chromium·Firefox·WebKit, Tomcat·Jetty·Reactor Netty, Nginx TLS Proxy와 Fault/Performance 시험이다. + +## 1. 문서 목적 + +이 문서는 HTTP Upgrade 이후 하나의 Connection이 생성되고 종료될 때까지 다음 전 과정을 구현 수준으로 고정한다. + +```text +HTTP Handshake / Upgrade +→ Origin·Authentication·Admission +→ Subprotocol negotiation +→ Connection Context +→ Local Session Registry +→ Frame receive·message assembly +→ Schema·size·authorization validation +→ Application Use Case +→ durable business evidence +→ response/event creation +→ session outbound queue +→ serialized transport write +→ heartbeat·idle·credential expiry +→ drain·close +→ reconnect hint +``` + +구현자가 다시 선택하지 않도록 다음을 확정한다. + +```text +Stable·Advanced 기능 경계 +Module dependency direction +Endpoint·Protocol·Codec profile +Handshake HTTP error contract +Origin·CSRF·browser authentication profile +One-time connection ticket +Typed message family와 wire envelope +Message catalog·schema versioning +Connection·Session state model +Inbound·Application·Outbound evidence +Request–Response correlation +Command idempotency와 completion unknown +Session-local registry +Application handler boundary +Message authorization +Ordering·sequence·gap detection +Outbound priority·loss policy·queue budget +Serialized writer +Heartbeat·idle·max age·credential expiry +Close code·typed error catalog +Servlet·WebFlux runtime 차이 +Nginx·TLS·Forwarded Header contract +Metric·Trace·Log cardinality +Admin snapshot·disconnect·drain +Browser·Proxy·Fault·Performance release gate +``` + +## 2. 플랫폼 정의와 비정의 + +```text +WebSocket Platform += Connection Runtime ++ Typed Protocol Runtime ++ Security Context ++ Resource Budget ++ Execution Evidence ++ Backpressure ++ Lifecycle / Drain ++ Reconnect Coordination ++ Admin / Test Plane + +WebSocket Platform +≠ Durable Message Broker +≠ GraphQL Subscription Engine +≠ gRPC Streaming Runtime +≠ File Transfer Platform +≠ Database Transaction Manager +≠ Business Workflow Engine +``` + +### 2.1 플랫폼이 소유한다 + +- endpoint와 subprotocol catalog +- Origin, handshake admission, one-time ticket integration +- 인증된 Actor·Tenant·Client Context +- Connection과 Session 수명주기 +- frame/message size, decode, schema version, rate budget +- request–response correlation과 timeout +- message-level authorization hook +- inbound·outbound·connection evidence +- outbound priority, loss semantics, queue budget와 serialized writer +- ordering profile, stream sequence와 gap detection +- heartbeat, idle timeout, credential expiry, max connection age +- typed error message와 close code mapping +- Servlet·WebFlux runtime adapter +- Nginx proxy contract +- bounded observability와 safe Admin Plane +- 실제 browser·container·proxy·fault·performance testkit + +### 2.2 Application이 소유한다 + +- Command·Query·Event의 업무 의미 +- Application Use Case와 transaction +- 객체·Tenant 권한 판정 +- 업무 idempotency scope와 semantic fingerprint +- committed result ledger의 저장 방식 +- stream snapshot·event schema의 업무 의미 +- presence를 제품에서 해석하는 방식 +- event history의 보존 기간과 snapshot 생성 + +### 2.3 인접 모듈 경계 + +| 인접 모듈 | 인접 모듈 소유 | WebSocket 연계 | +|---|---|---| +| `web` | Upgrade 전 Route, Forwarded Header, CORS·CSRF, HTTP Problem | `101` 이후 Context를 인계받음 | +| `security` | Token·Session 검증, Actor·Tenant·Permission 원천 | Connection·Message Context에 검증 결과 유지 | +| `messaging` | Durable Event, ACK, Retry, Replay, DLQ, Offset | live fan-out source·resume history 제공 | +| `redis` | TTL, atomic ticket consume, ephemeral Pub/Sub, presence primitive | ticket·session index·lossy fan-out adapter | +| `graphql` | `graphql-transport-ws`, operation·subscription·error | transport bridge만 사용 | +| `grpc` | 내부 typed RPC와 streaming | 브라우저 실시간 연결과 분리 | +| `fileserver` | Binary upload/download, Range, scan | file reference와 상태 event만 전달 | +| `notification` | Inbox·read state·provider delivery | 새 알림 live signal만 전달 | +| `jpa`·`mongodb` | transaction, repository, query | Application Use Case가 호출 | +| `httpclient` | 일반 outbound HTTP | outbound WebSocket client와 분리 | + +## 3. 기술 기준선과 호환성 Lane + +| 영역 | Stable 기준 | 정책 | +|---|---|---| +| Java | 21 | 플랫폼 최소선 | +| Spring Boot | 4.1 BOM | dependency Source of Truth | +| Spring Framework | Boot-managed 7.0.x | 개별 override 금지 | +| Servlet Runtime | Tomcat 11 우선 | Stable certification 기준 | +| Servlet Compatibility | Jetty 12.1 | 실제 TLS/Nginx 시험 | +| Reactive Runtime | WebFlux + Reactor Netty | 선택 Stable Profile | +| Stable Protocol | RFC 6455 + Typed JSON | `hyeonworks.realtime.v1.json` | +| Stable Codec | UTF-8 JSON | strict wire manifest | +| STOMP | 1.2 | Advanced Stable adapter | +| Binary Codec | Protobuf, CBOR | Advanced | +| Compression | RFC 7692 | endpoint opt-in Advanced | +| HTTP/2 WebSocket | RFC 8441 | Compatibility Lane | +| HTTP/3 WebSocket | RFC 9220 | Platform Experimental | +| SockJS | Legacy Compatibility | 신규 기본 제외 | +| Browser tests | Chromium·Firefox·WebKit | Stable Release Gate | +| Proxy tests | Nginx TLS | Stable Release Gate | + +### 3.1 Stable dependency 원칙 + +```text +websocket-core-api +→ Spring WebSocket, Servlet, Reactor, Netty, STOMP에 의존하지 않음 + +websocket-servlet +→ Spring Servlet WebSocket adapter + +websocket-webflux +→ Spring WebFlux WebSocket adapter + +starter-mvc +→ servlet runtime만 조합 + +starter-webflux +→ reactive runtime만 조합 + +stable starter +→ STOMP·Broker Relay·Cluster·Resume·Binary·Compression을 자동 포함하지 않음 +``` + +## 4. 공개 기능 계층 + +```text +WS1 Standard Typed WebSocket +- Endpoint profile +- JSON Typed Protocol +- Request–Response +- Command·Event +- Connection Context +- Origin·Authentication +- Heartbeat +- Bounded Queue +- Error·Close + +WS2 Advanced Messaging +- Subscription +- Application ACK +- Resume·Sequence Replay +- Protobuf·CBOR +- STOMP 1.2 + +WS3 Infrastructure Extension +- Multi-node fan-out +- Broker Relay +- Redis Session Index +- Presence +- Compression +- SockJS +- HTTP/2·3 + +WS4 Admin Plane +- Connection snapshot +- Endpoint drain +- Session disconnect +- Protocol disable +- Maintenance broadcast +``` + +## 5. 확정 모듈 구조 + +```text +modules/websocket/ +├── websocket-core-api +├── websocket-protocol +├── websocket-session +├── websocket-security +├── websocket-resilience +├── websocket-observability +├── websocket-servlet +├── websocket-webflux +├── websocket-admin +├── websocket-spring-boot-starter-mvc +├── websocket-spring-boot-starter-webflux +├── websocket-testkit-core +├── websocket-testkit-servlet +├── websocket-testkit-webflux +├── websocket-testkit-browser +├── websocket-testkit-proxy +└── websocket-testkit-fault + +modules/websocket-advanced/ +├── websocket-advanced-bootstrap +├── websocket-resume +├── websocket-cluster-redis +├── websocket-cluster-messaging +├── websocket-presence-redis +├── websocket-stomp +├── websocket-broker-relay-rabbit +├── websocket-protobuf +├── websocket-cbor +├── websocket-compression +├── websocket-outbound-client +├── websocket-sockjs-compat +├── websocket-http2-compat +├── websocket-http3-experimental +├── websocket-graphql-transport-bridge +├── websocket-testkit-stomp +├── websocket-testkit-cluster +└── websocket-testkit-advanced-browser +``` + +### 5.1 의존 방향 + +```text +core-api + ↑ +protocol / session / security / resilience / observability + ↑ +servlet webflux + ↑ ↑ +starter-mvc starter-webflux + +advanced modules +→ stable public contracts를 소비 +→ stable starter에 역의존 금지 +``` + +## 6. Endpoint와 Handshake 계약 + +### 6.1 Endpoint Profile + +```java +public record WebSocketEndpointProfile( + String endpointName, + String path, + Set subprotocols, + AuthenticationProfile authentication, + OriginPolicy originPolicy, + ConnectionBudget budget, + HeartbeatPolicy heartbeat, + OrderingProfile ordering, + boolean commandEnabled) { +} +``` + +Profile은 동적 URL이나 임의 Origin을 허용하지 않는다. Production에서 subprotocol 없는 Typed Endpoint를 허용하지 않는다. + +### 6.2 Handshake 의미 순서 + +```text +Trusted Proxy normalization +→ endpoint lookup +→ Host / Origin policy +→ HTTP authentication or ticket consume +→ Actor·Tenant candidate context +→ subprotocol negotiation +→ extension negotiation +→ connection admission +→ 101 +→ Session register +→ OPEN +``` + +### 6.3 Upgrade 전 HTTP 오류 + +| 상황 | 상태 | +|---|---:| +| malformed handshake | 400 | +| authentication required | 401 | +| Origin·endpoint denied | 403 | +| hidden endpoint | 404 | +| duplicate connection conflict | 409 | +| rate limit | 429 | +| draining·capacity unavailable | 503 | + +`101` 이후에는 HTTP Problem Details로 전환하지 않는다. + +## 7. 인증·Origin·CSRF + +### 7.1 Stable 인증 Profile + +```text +HTTP_SESSION +- HTTP Principal 승계 +- exact Origin 필수 +- credential expiry/max age 적용 + +ONE_TIME_TICKET +- HTTP API에서 발급 +- high entropy +- short TTL +- actor·tenant·endpoint·origin binding +- atomic one-time consume +``` + +### 7.2 비지원·Advanced + +```text +QUERY_LONG_LIVED_BEARER +→ 비지원 + +STOMP_CONNECT_BEARER +→ Advanced + +MID_CONNECTION_REAUTHENTICATION +→ Experimental +``` + +### 7.3 Origin 정책 + +- exact allowlist가 기본이다. +- `null` Origin은 기본 거부한다. +- wildcard subdomain은 사전 등록된 profile만 허용한다. +- Cookie 인증 + Origin 미검증 조합은 startup failure다. +- Client가 payload로 보낸 actor·tenant 값을 인증 원천으로 사용하지 않는다. + +## 8. Subprotocol·Message Schema·Codec + +### 8.1 Stable subprotocol + +```text +hyeonworks.realtime.v1.json +``` + +Production Endpoint에서 지원되는 공통 subprotocol이 없으면 Handshake를 거부한다. + +### 8.2 메시지 Family + +```text +REQUEST +RESPONSE +COMMAND +EVENT +ERROR +PING +PONG +CANCEL +COMPLETE +``` + +Subscription·ACK·RESUME·SNAPSHOT은 Advanced다. + +### 8.3 공통 Envelope + +```java +public record WebSocketEnvelope( + String type, + int version, + String messageId, + String correlationId, + String causationId, + String streamId, + Long sequence, + Instant occurredAt, + Instant expiresAt, + T payload) { +} +``` + +각 message family는 사용 가능한 field를 별도 schema로 제한한다. `Map`, Java FQCN type, Entity·Document, Java Serialization은 허용하지 않는다. + +### 8.4 Schema 진화 + +```text +message type ++ schema major version ++ codec +→ MessageCatalog key +``` + +- 동일 type/version의 wire schema를 배포 후 변경하지 않는다. +- additive optional field만 같은 compatible version에 허용한다. +- 의미 변경, required field 추가, type 변경은 새 version이다. +- unknown type/version은 업무 handler에 전달하지 않는다. +- JSON depth·array·string과 decoded bytes를 decode 전에 제한한다. + +## 9. Connection·Session 모델 + +### 9.1 상태 + +```text +CONNECTING +HANDSHAKE_ACCEPTED +AUTHENTICATED +OPEN +DRAINING +CLOSING +CLOSED +ABNORMAL +``` + +### 9.2 Context + +```java +public record WebSocketConnectionContext( + String connectionId, + String sessionId, + String endpointName, + String actorFingerprint, + String tenantFingerprint, + String clientInstanceId, + String protocol, + String protocolVersion, + String nodeId, + Instant connectedAt, + Instant credentialExpiresAt) { +} +``` + +실제 transport session은 연결을 소유한 node memory에만 존재한다. Session 객체를 Redis에 직렬화하지 않는다. + +## 10. 실행 증거 + +### 10.1 Inbound Evidence + +```text +FRAME_RECEIVED +MESSAGE_ASSEMBLED +MESSAGE_VALIDATED +MESSAGE_AUTHORIZED +APPLICATION_STARTED +APPLICATION_COMMITTED +APPLICATION_FAILED +``` + +### 10.2 Outbound Evidence + +```text +MESSAGE_CREATED +QUEUED +WRITE_STARTED +WRITTEN_LOCALLY +CLIENT_ACKED +CLIENT_APPLIED +EXPIRED +DROPPED +UNKNOWN +``` + +### 10.3 Connection Evidence + +```text +OPEN +HEARTBEAT_ALIVE +HALF_OPEN_SUSPECTED +DRAINING +CLOSE_SENT +CLOSE_RECEIVED +CLOSED +ABNORMAL +``` + +다음 승격은 금지한다. + +```text +WRITTEN_LOCALLY → CLIENT_ACKED +CLIENT_ACKED → CLIENT_APPLIED +APPLICATION_STARTED → APPLICATION_COMMITTED +STOMP RECEIPT → APPLICATION_COMMITTED +``` + +## 11. Application Adapter·Request–Response·Command + +### 11.1 Handler 경계 + +```text +Typed Message +→ transport validation +→ authorized request context +→ Application Command / Query +→ Application Use Case +→ Application Result +→ Typed response/event +``` + +Handler가 Repository, `MongoTemplate`, raw HTTP client, broker ACK, provider SDK를 직접 호출하지 않도록 architecture test를 둔다. + +### 11.2 Request–Response + +```text +request messageId +→ correlationId +→ one pending request registry entry +→ response / error / timeout / cancel +``` + +Connection별 pending request 수를 제한한다. 늦게 도착한 response는 bounded tombstone window로 분류하고 새 request에 잘못 연결하지 않는다. + +### 11.3 상태 변경 Command + +상태 변경 Command는 다음을 요구한다. + +```text +commandId +idempotencyKey +semantic fingerprint +expiresAt +``` + +Application transaction과 Result Ledger가 같은 원자 경계에 들어갈 수 있는 경우 이를 사용한다. Commit 후 socket reset은 `COMPLETION_UNKNOWN_TO_CLIENT`이며 같은 key 재요청은 기존 결과를 replay한다. + +## 12. Idempotency·Completion Unknown·Reconciliation + +```text +ABSENT +PROCESSING +COMMITTED +FAILED_TERMINAL +EXPIRED +``` + +다음 규칙을 적용한다. + +- 같은 scope·key·fingerprint의 `COMMITTED`는 저장된 결과를 반환한다. +- 같은 scope·key에 다른 fingerprint는 conflict다. +- `PROCESSING`은 새 업무 실행을 시작하지 않는다. +- Commit 여부를 모르면 자동 재실행하지 않고 reconciliation query를 사용한다. +- Connection sequence를 idempotency key로 사용하지 않는다. +- WebSocket runtime이 Application Commit을 추측하지 않는다. + +## 13. Inbound Pipeline과 Resource Budget + +### 13.1 의미 순서 + +```text +Frame receive +→ fragmentation assembly +→ assembled bytes budget +→ UTF-8 / JSON decode +→ structural budget +→ message catalog lookup +→ schema validation +→ authentication freshness +→ message authorization +→ rate/admission +→ application handler +``` + +### 13.2 Stable 초기 Hard Limit + +| 항목 | 초기값 | 정책 | +|---|---:|---| +| Text message | 64 KiB | endpoint에서 더 낮게 가능 | +| Binary message | Stable 비지원 | Advanced | +| JSON depth | 32 | 초과 즉시 거부 | +| Array elements | 1,000 | schema별 더 낮게 가능 | +| String bytes | 32 KiB | field validation 추가 | +| In-flight requests | 32 / connection | hard limit | +| Outbound queue | 512 KiB + count 256 | 양쪽 상한 | +| Subscriptions | Stable Raw 기본 미지원 | Advanced | +| Send stall | 10s 시작값 | benchmark 후 조정 | + +숫자는 플랫폼 초기 profile이며 실제 서비스는 더 작은 값만 기본 override할 수 있다. 상향은 성능 근거가 필요하다. + +## 14. Outbound Queue·Backpressure + +### 14.1 메시지 전달 성격 + +```text +LOSSLESS_CRITICAL +LOSSLESS_RESUMABLE +LOSSY_DROP_ALLOWED +LOSSY_COALESCE_BY_KEY +``` + +### 14.2 Overflow 정책 + +| 성격 | 정책 | +|---|---| +| 업무 결과 | Disconnect + reconciliation | +| 업무 상태 event | Disconnect + durable resume가 있으면 재개 | +| presence·typing | Drop 가능 | +| 최신 snapshot | Coalesce 가능 | +| security notice | 우선순위 또는 즉시 close | + +### 14.3 Writer 불변식 + +- 한 Session의 transport write는 항상 하나의 serialized writer만 수행한다. +- Application thread가 raw `sendMessage()`를 직접 호출하지 않는다. +- Queue는 message count와 byte size를 함께 계산한다. +- 전역 buffered bytes에도 hard limit을 둔다. +- `WRITTEN_LOCALLY`는 Client ACK가 아니다. + +## 15. Ordering·Sequence·Gap + +```text +UNORDERED_LOW_LATENCY +SESSION_ORDERED +SUBSCRIPTION_ORDERED +STREAM_KEY_ORDERED +``` + +Stable Raw는 `UNORDERED_LOW_LATENCY`, `SESSION_ORDERED`, `STREAM_KEY_ORDERED`를 제공한다. Stream ordering에는 `streamId + sequence`를 사용한다. + +```text +sequence <= lastApplied +→ duplicate + +sequence == lastApplied + 1 +→ apply + +sequence > lastApplied + 1 +→ GAP +``` + +Session 내부 처리 순서와 cross-node event 순서를 동일시하지 않는다. + +## 16. Heartbeat·Idle·Credential Lifetime + +```text +TCP keepalive +≠ WebSocket Ping/Pong +≠ Application heartbeat +≠ Proxy read timeout +≠ Presence TTL +``` + +Stable 초기 profile은 다음 관계를 요구한다. + +```text +heartbeatInterval < heartbeatTimeout < proxyReadTimeout +``` + +Connection credential이 만료되거나 권한이 회수되면 Connection을 종료하고 새 인증으로 재연결한다. Mid-connection re-auth는 Stable에서 제공하지 않는다. + +## 17. Error Message·Close Code + +### 17.1 Standard·Private mapping + +| 상황 | Close | +|---|---:| +| 정상 완료 | 1000 | +| protocol error | 1002 | +| unsupported data | 1003 | +| invalid payload | 1007 | +| policy violation | 1008 | +| message too big | 1009 | +| internal error | 1011 | +| service restart | 1012 | +| temporary overload | 1013 | +| authentication required | 4401 | +| access denied | 4403 | +| heartbeat timeout | 4408 | +| duplicate connection | 4409 | +| validation failure | 4422 | +| rate limited | 4429 | +| overloaded | 4503 | + +Typed `ERROR`를 보낼 수 있으면 먼저 보내고, 보안·프로토콜·자원 상태에 따라 Close한다. Close reason에는 stack trace, SQL, token, PII를 포함하지 않는다. + +## 18. Servlet Runtime + +- Tomcat이 Stable 기본이다. +- Jetty는 호환 Lane이다. +- `ConcurrentWebSocketSessionDecorator` 또는 동등한 single-writer wrapper를 사용한다. +- send time limit과 buffer limit을 endpoint profile에서 적용한다. +- blocking Application Use Case는 bounded executor에서 실행한다. +- container session을 일반 애플리케이션 API로 반환하지 않는다. + +## 19. WebFlux Runtime + +- Reactor Netty가 Stable 선택 기준이다. +- `receive()`와 `send(Publisher)` lifecycle을 하나의 handler pipeline으로 결합한다. +- blocking JPA·SDK 호출을 event loop에서 실행하지 않는다. +- pooled `DataBuffer`를 async boundary 뒤에 보관하면 retain/release 계약을 지킨다. +- Reactive Streams가 browser까지 end-to-end demand protocol을 제공한다고 광고하지 않는다. +- Server-side bounded queue와 slow consumer 정책은 동일하게 적용한다. + +## 20. Nginx·TLS·Forwarded Header + +```text +Internet +→ Nginx TLS termination +→ incoming Forwarded/X-Forwarded-* 제거 +→ trusted value 재설정 +→ Upgrade·Connection 전달 +→ backend WebSocket runtime +``` + +실제 경로는 최소 다음을 시험한다. + +```text +wss://hyeonworks.com/ws +wss://hyeonworks.com/dev-ws +``` + +기존 `/api`, `/dev-api` 아래에 배치할 경우 prefix rewrite와 Origin·Location 계산을 별도 시험한다. + +Stable Nginx contract에는 다음이 포함된다. + +```text +proxy_http_version 1.1 +Upgrade +Connection +proxy_read_timeout +proxy_send_timeout +TLS +connection limit +access-log redaction +reload / graceful shutdown +``` + +## 21. Session Registry·Multi-node·Resume 경계 + +Stable은 node-local actual session registry만 제공한다. 외부 session index, cross-node fan-out, durable resume는 Advanced다. + +```text +actual WebSocket session +→ node memory only + +external session summary +→ Redis TTL index, Advanced + +replay history +→ Messaging/Event Log, Advanced bridge +``` + +Sticky Session은 connection lifetime 중 routing 수단일 뿐 reconnect·resume·drain 해결책으로 간주하지 않는다. + +## 22. STOMP 경계 + +STOMP는 Advanced Adapter다. + +```text +RECEIPT +≠ Application Commit + +ACK +≠ 보편적 exactly-once + +/queue 이름 +≠ durable queue 보장 + +Simple Broker +≠ clustered broker +``` + +Simple Broker는 Local/Test 단일 인스턴스에 제한한다. Multi-node는 external Broker Relay와 broker capability contract가 필요하다. + +## 23. Observability + +### 23.1 Metric + +```text +connection opened / rejected / closed +active connections +session duration +abnormal close +heartbeat timeout +inbound / outbound messages and bytes +validation / authorization failure +application failure +outbound queue bytes / messages +send duration +slow consumer +buffer overflow disconnect +sequence gap +idempotency replay +completion unknown +``` + +### 23.2 허용 Tag + +```text +endpointProfile +protocol +protocolVersion +messageTypeCatalog +closeCode +outcome +node +``` + +### 23.3 금지 Tag + +```text +sessionId +connectionId +messageId +userId +raw tenantId +resourceId +idempotencyKey +payload +token +full dynamic destination +``` + +## 24. Admin Plane + +안전한 Admin 기능은 다음으로 제한한다. + +```text +connection summary +endpoint / protocol usage +close-code distribution +slow-consumer summary +endpoint drain +specific session disconnect +actor session disconnect +maintenance broadcast +``` + +Payload·credential·전체 subscription filter를 기본 노출하지 않는다. 모든 변경 기능은 감사 기록을 남긴다. + +## 25. Spring Boot 설정 계약 + +```yaml +backend: + websocket: + stack: SERVLET + node-id: ${HOSTNAME:local} + + endpoints: + realtime-v1: + path: /ws + subprotocols: + - hyeonworks.realtime.v1.json + authentication: ONE_TIME_TICKET + allowed-origins: + - https://hyeonworks.com + max-connections: 10000 + max-connections-per-actor: 8 + max-text-message-bytes: 65536 + max-in-flight-requests: 32 + outbound-queue-bytes: 524288 + outbound-queue-messages: 256 + send-time-limit: 10s + heartbeat-interval: 25s + heartbeat-timeout: 55s + max-connection-age: 2h +``` + +숫자는 초기 profile이며 production 승격은 실제 부하 시험을 요구한다. + +## 26. Startup Validation + +다음 설정은 startup failure다. + +```text +MVC와 WebFlux Starter 동시 활성화 +Production Typed Endpoint에 subprotocol 없음 +Cookie 인증 Endpoint에 Origin allowlist 없음 +Wildcard Origin + credential +long-lived query token auth +message·queue·connection budget가 0 또는 무제한 +heartbeatTimeout <= heartbeatInterval +proxyReadTimeout <= heartbeatTimeout +raw WebSocketSession을 Application module에 노출 +stable starter가 STOMP·Cluster·Resume를 자동 포함 +production ws plaintext +production trust-all TLS +``` + +## 27. 테스트 전략 + +### 27.1 Contract + +```text +Handshake·Origin·Authentication·Ticket +Subprotocol·Message Catalog·JSON +Request–Response·Timeout·Cancel +Execution Evidence·Idempotency·Reconciliation +Ordering·Sequence·Gap +Queue·Writer·Slow Consumer +Heartbeat·Credential Expiry·Close +Admin·Observability cardinality +``` + +### 27.2 Runtime Matrix + +```text +Tomcat + Nginx + TLS +Jetty + Nginx + TLS +Reactor Netty + Nginx + TLS +Chromium +Firefox +WebKit +``` + +### 27.3 Fault Matrix + +```text +commit 직후 socket reset +write 시작 후 reset +Nginx idle timeout +half-open connection +node kill -9 +browser sleep / wake +network switch +ticket replay +reconnect storm +slow consumer 1 / 10 / 50 percent +``` + +### 27.4 Release Gate 핵심 회귀 + +```text +Application Commit 직후 TCP reset +→ client response 미관측 +→ 동일 idempotency key 재요청 +→ 기존 result replay +→ business side effect count = 1 +``` + +```text +slow client 10% +→ global buffered bytes hard limit 유지 +→ fast client p99 budget 유지 +→ critical message drop = 0 +``` + +## 28. 구현 단계 + +```text +Foundation +→ Contract·Endpoint·Protocol·Context·Evidence + +Security·Handshake +→ Origin·Ticket·Admission + +Raw Typed Runtime +→ JSON·Catalog·Handler·Request–Response + +Reliability +→ Idempotency·Result Ledger·Completion Unknown + +Backpressure·Ordering +→ Queue·Writer·Sequence·Heartbeat + +Runtime Adapters +→ Tomcat·Jetty·Reactor Netty + +Operations +→ Nginx·Observability·Admin·Drain + +Verification +→ Browser·Fault·Security·Performance Release Gate +``` + +## 29. Stable 완료 정의 + +Stable은 다음을 모두 만족해야 한다. + +- Raw Typed JSON protocol이 실제 browser와 Nginx TLS 경로에서 동작한다. +- 모든 Endpoint가 Origin·Authentication·Subprotocol·Budget profile을 가진다. +- Inbound·Outbound·Connection Evidence가 분리돼 있다. +- 상태 변경 Command의 Commit evidence는 durable ledger로 증명된다. +- Commit 후 response loss에서 mutation이 중복 실행되지 않는다. +- 한 Session의 outbound transport write가 직렬화된다. +- Queue와 전역 buffer가 bounded다. +- critical message는 overflow에서 silently drop되지 않는다. +- sequence gap과 duplicate를 탐지한다. +- heartbeat·idle·credential expiry·max age가 Nginx timeout과 정렬된다. +- Tomcat·Jetty·Reactor Netty의 계약이 실제 연결에서 검증된다. +- Chromium·Firefox·WebKit browser matrix가 통과한다. +- security abuse·slow consumer·rolling restart gate가 통과한다. +- Stable starter가 Advanced dependency를 포함하지 않는다. + +## 30. 비지원 선언 + +```text +WebSocket exactly-once +Connection sequence 기반 business idempotency +WebSocket 자체 durable replay·DLQ +Simple Broker의 clustered durability +대형 file transport +Java serialization +long-lived access token query parameter +unbounded queue·connection·message +Client application 적용을 server write로 추정 +``` + +--- + +## Appendix A. 요구사항 원본 — WebSocket 심층 리서치 + +# WebSocket 실시간 양방향 연결 실행 플랫폼 심층 리서치 + +## 결론과 기술 기준선 + +이번 조사에서 가장 중요한 결론은 **WebSocket 모듈의 안정성을 “연결이 살아 있고 `sendMessage()`가 성공했는가”로 정의해서는 안 된다**는 것입니다. RFC 6455 WebSocket은 HTTP Upgrade 이후 양방향 메시지를 운반하는 저수준 프로토콜이고, 애플리케이션 메시지의 라우팅·업무 처리 완료·구독·ACK·재전송·Resume 의미를 정의하지 않습니다. Spring Framework 역시 WebSocket 자체는 메시지 내용의 의미를 정의하지 않으므로 STOMP 같은 subprotocol을 협상하거나 애플리케이션 규약을 별도로 만들어야 한다고 설명합니다. citeturn17search0turn7view2 + +따라서 권장 모델은 제시하신 **접근 C, `공통 Connection Runtime + Protocol Adapter`**입니다. + +```text +HTTP Handshake / Upgrade + ↓ +WebSocket Connection Runtime +├─ Origin / Authentication / Admission +├─ Connection Context +├─ Local Session Registry +├─ Inbound Assembly / Budget +├─ Outbound Serialized Writer +├─ Bounded Queue / Backpressure +├─ Heartbeat / Idle / Max Age +├─ Security / Observability +├─ Drain / Disconnect +└─ Reconnect Coordination + ↓ +Protocol Adapter +├─ Raw Typed Protocol +├─ STOMP 1.2 +├─ GraphQL Subscription Bridge +└─ Provider-specific Protocol + ↓ +Protocol Command / Query / Event + ↓ +Application Use Case + ↓ +JPA / MongoDB / Messaging / Redis / HTTP Client +``` + +이 구조의 핵심은 **Connection Runtime의 운영 의미와 Protocol Adapter의 메시지 의미를 분리하는 것**입니다. STOMP의 `ACK`, `RECEIPT`, Destination 의미를 Raw Typed Protocol에 억지로 투영해서도 안 되고, 반대로 자체 Raw Protocol의 `messageId`, `sequence`, `resumeToken`을 STOMP 표준 기능인 것처럼 선언해서도 안 됩니다. STOMP 자체도 Destination 문자열을 opaque한 값으로 취급하며, 실제 전달·신뢰성 의미는 서버와 Destination 구현에 따라 달라진다고 명시합니다. citeturn21search0 + +### 현재 기술 기준 + +2026년 8월 14일 기준 Spring Boot 문서의 Stable은 `4.1.0`이며, Boot 4.1.0은 Spring Framework `7.0.8+`를 요구합니다. Java 최소 요구는 17이고 Java 26까지 호환되므로, Backend Skeleton이 Java 21을 자체 기준선으로 고정하는 것은 충분히 합리적인 플랫폼 정책입니다. Embedded Servlet Container 기준으로 Boot 4.1.0은 Tomcat 11.0.x와 Jetty 12.1.x를 지원합니다. citeturn18search1 + +Spring Boot 4.1은 embedded Tomcat과 Jetty의 WebSocket 자동 구성을 제공하고 MVC 애플리케이션에서는 `spring-boot-starter-websocket`을 제공하며, reactive 애플리케이션은 WebSocket API와 `spring-boot-starter-webflux` 조합을 사용합니다. Spring Boot의 reactive server 지원 범위에는 Reactor Netty, Tomcat, Jetty가 있으며, WebFlux 쪽 기본 운영 후보는 Reactor Netty가 적절합니다. citeturn18search0turn18search3 + +| 영역 | 조사 결론 | 플랫폼 등급 | +|---|---|---| +| Java | **21 기준선**. Spring 최소값보다 플랫폼 기준을 높게 고정 | Stable | +| Spring Boot | **4.1 BOM** | Stable | +| Spring Framework | Boot 관리 `7.0.x`, 현재 Boot 최소 `7.0.8` | Stable | +| Servlet Raw WebSocket | Tomcat 기본, Jetty 호환 Lane | Stable | +| Reactive WebSocket | WebFlux + Reactor Netty 기본 | Stable 선택 | +| Raw Text Protocol | UTF-8 JSON Typed Envelope | **Stable 기본** | +| Raw Binary | Protobuf 우선 검토, CBOR 선택 | Advanced | +| STOMP | STOMP 1.2 Adapter | Advanced Stable | +| Simple Broker | Local/Test·단일 인스턴스 제한 | 제한 지원 | +| External Broker Relay | Broker capability 검증 후 | Advanced | +| SockJS | 신규 서비스 기본 제외 | Legacy Compatibility | +| `permessage-deflate` | Endpoint별 명시적 Opt-in | Advanced | +| HTTP/2 WebSocket | RFC 8441 경로별 E2E 검증 | Compatibility | +| HTTP/3 WebSocket | RFC 9220은 표준이 존재하나 플랫폼 채택은 별도 | Experimental | +| 대형 파일 전송 | Fileserver/Object Storage 사용 | 비지원 | +| Durable ACK·DLQ·Replay | Messaging 소유 | WebSocket 비지원 | + +HTTP/2와 HTTP/3에서 WebSocket을 구성하는 표준 자체는 각각 RFC 8441과 RFC 9220으로 이미 존재합니다. 따라서 “HTTP/3 WebSocket 프로토콜이 실험적”이라고 표현하기보다는, **표준은 존재하지만 Backend Skeleton에서 Client–Nginx/Ingress–Runtime 전체 경로 검증이 끝나지 않았으므로 플랫폼 기능 등급을 Experimental로 둔다**고 표현하는 것이 정확합니다. RFC 9220은 HTTP/3의 Extended CONNECT를 WebSocket에 적용합니다. citeturn22search1turn7view3 + +### 핵심 질문에 대한 답 + +한 메시지의 실행 상태는 다음 한 줄로 표현해서는 안 됩니다. + +```text +DELIVERED = true / false +``` + +대신 적어도 세 증거 축이 필요합니다. + +```text +Inbound Evidence +FRAME_RECEIVED +→ MESSAGE_ASSEMBLED +→ MESSAGE_VALIDATED +→ MESSAGE_AUTHORIZED +→ APPLICATION_STARTED +→ APPLICATION_COMMITTED | APPLICATION_FAILED + +Outbound Evidence +MESSAGE_CREATED +→ QUEUED +→ WRITE_STARTED +→ WRITTEN_TO_LOCAL_TRANSPORT +→ CLIENT_RECEIVED_ACK? // 별도 Protocol이 있을 때만 +→ CLIENT_APPLIED_ACK? // 별도 Application ACK가 있을 때만 + +Connection Evidence +OPEN +→ HEARTBEAT_ALIVE +→ SUSPECTED_HALF_OPEN +→ DRAINING +→ CLOSE_SENT / CLOSE_RECEIVED +→ CLOSED | ABNORMAL +``` + +즉, + +```text +sendMessage() 성공 +≠ Client 수신 + +Client 수신 +≠ Client Application 적용 + +STOMP RECEIPT +≠ Application Transaction Commit + +TCP/WebSocket 연결 유지 +≠ Client Application 정상 + +Application Commit +≠ Response가 Client에게 관측됨 +``` + +이어야 합니다. STOMP 1.2의 `RECEIPT`은 해당 Client Frame을 서버가 처리했다는 증거이며 이전 Frame들이 서버에 수신됐다는 누적 증거로 쓸 수 있지만, 규격은 이전 Frame들이 완전히 처리되었다는 뜻은 아니라고 명시합니다. 따라서 `RECEIPT`을 업무 트랜잭션 커밋 증거로 바꾸어 해석하면 안 됩니다. citeturn11view1turn21search0 + +이 판단이 전체 플랫폼 설계의 중심축이어야 합니다. + +## 책임 경계와 공개 계층·모듈 구조 + +### 인접 플랫폼과의 경계 + +제시하신 경계는 전반적으로 타당합니다. 특히 **Messaging과 WebSocket 사이의 경계가 가장 중요**합니다. Spring도 WebSocket을 HTTP와 다른 비동기 메시징 구조라고 설명하지만, 그것이 곧 durable messaging을 의미하지는 않습니다. STOMP 역시 Reliability와 Destination의 실제 의미를 서버별 구현에 맡깁니다. citeturn17search0turn21search0 + +| 인접 모듈 | WebSocket이 소유 | 인접 모듈이 소유 | +|---|---|---| +| `web` | Upgrade 성공 이후 Connection Runtime | HTTP Route, Forwarded Header 정규화, HTTP 인증 진입, Upgrade 이전 오류 | +| `security` | 인증 결과를 Connection Context에 유지, 메시지 권한 적용 | Token·Session 검증, Actor·Tenant·Permission 원천 | +| `messaging` | 현재 연결 Session으로 Live Push | **Durable Event, ACK, Retry, Replay, DLQ, Offset** | +| `redis` | Presence·Session Index·Ephemeral fan-out 사용 | TTL·원자 연산·Pub/Sub 자체 의미론 | +| `graphql` | WebSocket transport adapter | GraphQL operation, subscription, GraphQL error semantics | +| `grpc` | 브라우저·Client 중심 장기 양방향 연결 | 내부 서비스 typed RPC와 gRPC streaming | +| `fileserver` | 파일 상태·reference event | 대용량 byte 업·다운로드, Range, 검사 | +| `notification` | “새 알림 있음” live signal | Inbox·읽음 상태·채널 전달 상태 | +| `jpa`·`mongodb` | Application Use Case 호출 | Transaction, Query, Repository | +| `httpclient` | WebSocket 외 일반 outbound HTTP와 분리 | HTTP 호출·retry 정책 | + +특히 다음 연결은 금지하는 것이 좋습니다. + +```text +WebSocketHandler + → JpaRepository 직접 호출 + +@MessageMapping + → MongoTemplate 직접 상태 전이 + +WebSocket Session + → Kafka ACK 의미를 직접 흉내냄 + +Redis Pub/Sub + → durable replay라고 선언 + +WebSocket Binary Frame + → 대형 파일 업로드 + +STOMP /queue/** + → 이름만 보고 durable queue라고 선언 +``` + +STOMP 규격상 `/queue/foo`라는 문자열 자체에는 Queue durability 같은 의미가 없습니다. Destination은 서버 구현이 해석하는 opaque string이고, 전달 신뢰성도 Destination과 Broker별 설정에 달려 있습니다. citeturn21search0 + +### 공개 기능 계층 + +권장 공개 계층은 다음과 같습니다. + +| 공개층 | 공개 대상 | 포함 기능 | +|---|---|---| +| **WS1 Standard Typed WebSocket** | 일반 애플리케이션 | Endpoint, JSON Typed Message, Request–Response, Event, Context, Auth, Heartbeat, Bounded Queue | +| **WS2 Advanced Messaging** | 고급 실시간 서비스 | Subscription, Application ACK, Resume, Sequence, Binary Codec, STOMP | +| **WS3 Infrastructure Extension** | 플랫폼·인프라 | Broker Relay, Multi-node Fan-out, Compression, SockJS, H2/H3 Profile | +| **WS4 Admin Plane** | 운영자 | Session Drain, Disconnect, Protocol disable, Connection snapshot, maintenance broadcast | + +일반 도메인 개발자에게는 `WebSocketSession`, Reactor Netty native channel, 임의 `SimpMessagingTemplate`, raw Destination 생성, `ConcurrentWebSocketSessionDecorator` 구성 등을 직접 노출하기보다 등록된 Endpoint/Profile/Message Catalog를 제공하는 편이 좋습니다. Servlet WebSocket의 underlying standard session은 concurrent send를 직접 안전하게 제공하지 않기 때문에 Spring도 동기화 또는 `ConcurrentWebSocketSessionDecorator` 사용을 안내합니다. citeturn0search1turn12search0 + +### 권장 의존성 구조 + +제시한 모듈 분리는 그대로 채택할 가치가 높습니다. + +```text +modules/websocket/ +├── websocket-core-api +├── websocket-protocol +├── websocket-session +├── websocket-security +├── websocket-resilience +├── websocket-observability +│ +├── websocket-servlet +├── websocket-webflux +│ +├── websocket-stomp +├── websocket-broker-relay +├── websocket-cluster +├── websocket-resume +├── websocket-client +├── websocket-admin +│ +├── websocket-spring-boot-starter-mvc +├── websocket-spring-boot-starter-webflux +│ +├── websocket-testkit-core +├── websocket-testkit-servlet +├── websocket-testkit-webflux +├── websocket-testkit-browser +└── websocket-testkit-proxy +``` + +의존 방향은 다음처럼 단방향으로 고정하는 것이 좋습니다. + +```text +core-api + ↑ +protocol / session / security / resilience / observability + ↑ +servlet webflux + ↑ ↑ +starter-mvc starter-webflux + +stomp + └─ broker-relay + +resume + └─ messaging bridge abstraction + +cluster + └─ redis / messaging capability adapter + +admin + └─ session abstraction +``` + +`core-api`에는 Jakarta WebSocket, Servlet, Reactor, Netty, STOMP 타입을 넣지 않는 편이 좋습니다. 같은 논리로 MVC와 WebFlux Starter도 상호 배타적으로 두는 것이 안전합니다. Spring Boot는 servlet과 reactive 스택을 별도로 구성하며, `spring-boot-starter-web`과 `spring-boot-starter-webflux`가 함께 있으면 기본적으로 MVC를 선택하므로 우연한 Stack 선택을 피하는 정책이 필요합니다. citeturn18search3 + +기본 Starter에서는 다음을 제외하는 것이 적절합니다. + +```text +websocket-stomp +websocket-broker-relay +websocket-cluster +websocket-resume +websocket-client +SockJS +permessage-deflate +HTTP/2 WebSocket Profile +HTTP/3 WebSocket Profile +``` + +이는 “지원하지 않는다”가 아니라 **WS1의 기본 런타임을 가볍고 예측 가능하게 유지하면서 WS2·WS3 기능은 명시적으로 선택하게 한다**는 의미입니다. + +## Handshake·인증·보안·Typed Protocol 계약 + +### Handshake는 HTTP와 WebSocket의 경계선이다 + +Classic WebSocket은 HTTP request로 시작하여 성공 시 `101 Switching Protocols`로 전환됩니다. Spring Framework도 Upgrade 전과 후가 전혀 다른 프로그래밍 모델임을 강조하며, Upgrade 이후에는 한 연결을 통해 애플리케이션 메시지가 계속 흐릅니다. citeturn17search0turn7view2 + +권장 파이프라인은 다음과 같습니다. + +```text +HTTP Request +→ Trusted Proxy / Forwarded Header 정규화 +→ WebSocket Endpoint Profile 선택 +→ Host / Origin 검증 +→ HTTP Authentication 또는 Connection Ticket 검증 +→ Actor / Tenant 후보 Context 생성 +→ Subprotocol 협상 +→ Extension 협상 +→ Connection / Tenant / IP Admission +→ 101 Switching Protocols +→ Protocol-level Authentication 완료 +→ Session OPEN +``` + +Upgrade 이전에는 기존 `web` 플랫폼의 HTTP 오류 계약을 그대로 사용할 수 있습니다. + +| Handshake 상황 | 권장 HTTP 결과 | +|---|---| +| Handshake 구조 오류 | `400` | +| 필수 HTTP 인증 실패 | `401` | +| Origin·Endpoint 권한 거부 | `403` | +| 존재 은닉이 필요한 Endpoint | `404` | +| 중복 Connection 정책 충돌 | `409` | +| 연결 Rate Limit | `429` | +| Drain·과부하 Admission 거부 | `503` | + +반대로 `101` 이후에는 HTTP `ProblemDetail`을 보낼 수 없으므로 Typed `ERROR` message 또는 WebSocket Close code로 전환해야 합니다. RFC 6455에서도 `101` 이외의 Handshake response는 HTTP semantics를 유지하지만 성공적으로 protocol switch가 완료된 뒤에는 WebSocket framing으로 통신합니다. citeturn8view2turn17search0 + +### Origin 검증은 필수 보안 경계 + +Spring Security 공식 문서는 브라우저의 WebSocket 연결에는 일반적인 Same Origin Policy가 자동 적용되지 않으므로 서버가 이를 명시적으로 보호해야 한다고 강조합니다. Cookie 인증 상태에서 Origin을 무제한으로 허용하면 다른 사이트가 사용자의 인증 상태를 이용하는 Cross-Site WebSocket Hijacking 문제가 생길 수 있습니다. Spring Security는 STOMP 구성에서 `CONNECT`에 CSRF token을 요구하는 방식을 제공합니다. citeturn17search1 + +Stable 기본 정책은 다음이 적절합니다. + +```text +Origin +→ Exact Allowlist + +Wildcard Subdomain +→ 기본 금지, 등록 Profile만 허용 + +null Origin +→ 기본 거부 + +Cookie Authentication +→ Origin 검증 필수 + +STOMP + Cookie Session +→ CONNECT CSRF 사용 + +Cross-origin Token Profile +→ Endpoint별 명시 Opt-in +``` + +다음은 기본 금지로 두는 것이 좋습니다. + +```text +allowedOrigins = * +Cookie Authentication + Origin 미검증 +요청 Origin을 그대로 Allow +장기 Access Token을 query parameter에 사용 +모든 STOMP MESSAGE / SUBSCRIBE permitAll +Client가 보낸 actorId / tenantId 신뢰 +``` + +Spring Security는 STOMP에서 inbound `MESSAGE`와 `SUBSCRIBE`를 Destination별로 통제할 수 있으며, 특히 broker prefix로 직접 MESSAGE를 보내 시스템 발신자를 가장하거나 다른 사용자용 Destination을 SUBSCRIBE하는 형태를 막아야 한다고 설명합니다. citeturn17search1 + +### 브라우저 인증 Profile + +브라우저 표준 `WebSocket` 생성 인터페이스는 URL과 subprotocol을 중심으로 제공되며 애플리케이션이 일반 HTTP client처럼 임의의 `Authorization` 헤더를 자유롭게 추가하는 인터페이스는 제공하지 않습니다. 반면 Handshake는 브라우저 credential 정책에 따라 Cookie 등의 인증정보를 사용할 수 있습니다. citeturn7view0turn5search1 + +따라서 다음과 같이 분리하는 것이 좋습니다. + +| 인증 Profile | 지원 등급 | 권장 의미 | +|---|---|---| +| Cookie / HTTP Session | Stable | HTTP 인증 Principal 승계 + Exact Origin | +| One-time Connection Ticket | **Stable 권장** | Bearer를 URL에 장기 노출하지 않는 브라우저 연결 | +| STOMP `CONNECT` Bearer | Advanced | `ChannelInterceptor`에서 인증 | +| Query Long-lived Access Token | 비지원 | Proxy·Access Log·History 노출 위험 | +| Protocol 중간 Re-auth | Experimental | 복잡성이 크므로 초기 Stable 제외 | + +Spring Security는 HTTP Handshake에서 인증된 `Principal`을 WebSocket으로 넘겨주는 모델을 지원합니다. STOMP에서 별도 token 인증을 원할 경우 `CONNECT` frame의 header를 `ChannelInterceptor`에서 처리할 수 있습니다. citeturn17search1turn5search1 + +One-time Ticket은 표준 기능이 아니라 **플랫폼 자체 Profile**로 설계해야 합니다. + +```text +POST /websocket-tickets +→ ticket 발급 + +Ticket: +- random high-entropy identifier +- 매우 짧은 TTL +- one-time atomic consumption +- actor binding +- tenant binding +- endpoint binding +- origin binding +- clientInstanceId 선택 binding +``` + +그리고 Access Log에는 Ticket 전체 값을 남기지 않는 것이 적절합니다. + +장기 Connection의 Credential 만료 정책은 Stable에서 **“만료·권한회수 시 현재 Connection 종료 → 새 Credential로 재연결”**을 기본으로 잡는 편이 낫습니다. Connection 내부 reauthentication은 state machine·race condition·권한 회수 처리 비용이 커지므로 Advanced/Experimental로 남기는 편이 안전합니다. + +### Subprotocol은 Production Endpoint에서 명시적으로 협상 + +WebSocket 표준은 `Sec-WebSocket-Protocol`로 상위 protocol을 협상할 수 있습니다. Spring도 STOMP 등의 고수준 protocol을 이 header를 통해 선택하는 것을 지원합니다. citeturn8view2turn17search0 + +권장 Raw protocol 이름은 다음처럼 **Major version + codec**을 포함하는 형태입니다. + +```text +hyeonworks.realtime.v1.json +hyeonworks.realtime.v1.protobuf +v12.stomp +graphql-transport-ws +``` + +Production Typed Endpoint는: + +```text +지원되는 Subprotocol 하나 선택 +→ 성공 + +지원되는 공통 Protocol 없음 +→ Handshake 거부 +``` + +로 처리하고, subprotocol 없이 “아무 JSON이나 받아들이는” 모드는 Local 또는 Compatibility profile로 제한하는 것을 권장합니다. + +### Typed Message Envelope + +Stable Raw JSON protocol에는 무조건 모든 필드를 넣는 것이 아니라 **공통 식별 필드 + 메시지 종류별 선택 필드**를 두는 편이 좋습니다. + +```json +{ + "type": "document.updated", + "version": 1, + "messageId": "01K...", + "correlationId": "01K...", + "streamId": "document:abc", + "sequence": 42, + "occurredAt": "2026-08-14T06:00:00Z", + "expiresAt": "2026-08-14T06:01:00Z", + "payload": {} +} +``` + +권장 의미는 다음과 같습니다. + +| 필드 | 계약 | +|---|---| +| `type` | 등록된 stable wire name | +| `version` | 해당 message schema major/version | +| `messageId` | 메시지 인스턴스 식별 | +| `correlationId` | Request–Response 연결 | +| `causationId` | 필요 시 원인 message | +| `streamId` | Ordering·Resume 대상 logical stream에서만 | +| `sequence` | 해당 stream 내부 monotonically increasing sequence | +| `occurredAt` | 서버 기준 이벤트 시각 | +| `expiresAt` | 오래된 Command 재실행 방지용 선택 필드 | +| `payload` | Message type별 DTO | + +모든 메시지에 `sequence`, `idempotencyKey`, `subscriptionId`를 강제하기보다 메시지 family별 schema를 만드는 것이 좋습니다. + +```text +RequestMessage +ResponseMessage +CommandMessage +EventMessage +SubscribeMessage +AckMessage +ErrorMessage +ResumeMessage +SnapshotMessage +``` + +다음은 금지하는 것이 적절합니다. + +```text +messageType = Java FQCN +payload = Map +Entity 자체 직렬화 +Java Serialization +무제한 polymorphic deserialization +한 Envelope 안에 모든 command payload union 수작업 +``` + +### Codec 정책 + +| Codec | 등급 | 정책 | +|---|---|---| +| UTF-8 JSON | Stable 기본 | Browser 친화적, Contract Test 필수 | +| Protobuf Binary | Advanced 권장 | 강한 schema가 필요한 고성능 client | +| CBOR | Advanced 선택 | 실제 요구·SDK 지원이 있을 때 | +| Raw Binary | 제한 | 사전 등록된 Message Profile만 | +| Java Serialization | 비지원 | Wire contract로 사용하지 않음 | + +대용량 byte는 Typed Event에서 object/file reference를 전달하고 fileserver/object-storage가 byte transport를 담당하도록 유지하는 것이 좋습니다. + +## 실행 증거·Idempotency·Ordering·ACK·Resume 계약 + +### 증거 모델은 플랫폼의 핵심 API여야 한다 + +질문의 핵심인 “어디까지 갔는가”를 정확히 답하려면 다음 단계가 필요합니다. + +| 단계 | 서버가 증명할 수 있는 것 | 재호출 판단 | +|---|---|---| +| `FRAME_RECEIVED` | WebSocket frame이 runtime에 도착 | 업무 실행 여부는 모름 | +| `MESSAGE_ASSEMBLED` | Fragment 조립 완료 | 아직 재실행 안전 | +| `MESSAGE_VALIDATED` | Protocol/schema 검증 완료 | 아직 업무 미실행 | +| `MESSAGE_AUTHORIZED` | Transport/message 권한 통과 | 아직 업무 미실행 | +| `APPLICATION_STARTED` | Use Case 진입 | **Commit 여부 불명확 가능** | +| `APPLICATION_COMMITTED` | 업무 결과의 durable evidence 존재 | 다시 실행하지 않고 결과 Replay | +| `APPLICATION_FAILED` | 정의된 실패로 종료 | 실패 유형에 따라 재시도 | +| `RESPONSE_QUEUED` | Outbound queue에 등록 | Client 수신 증거 아님 | +| `WRITE_STARTED` | local transport 쓰기 시작 | Client 수신 증거 아님 | +| `WRITTEN_LOCALLY` | framework/local transport 단계 완료 | Client 적용 증거 아님 | +| `CLIENT_ACKED` | protocol ACK를 Client가 보냄 | ACK 정의 범위만 증명 | +| `CLIENT_APPLIED` | app-level 적용 ACK가 존재 | Client application 반영 증거 | + +여기서 `APPLICATION_COMMITTED`는 WebSocket runtime 자체가 추측하면 안 됩니다. **Application transaction과 Idempotency/Result ledger가 durable evidence를 제공해야 합니다.** + +예: + +```text +Command(commandId, idempotencyKey) + ↓ +Application Use Case + ↓ +DB Transaction +├─ Domain State 변경 +└─ Command Result / Idempotency Record 저장 + ↓ COMMIT +APPLICATION_COMMITTED +``` + +그 뒤 소켓이 끊겨도 결과는 다음 연결에서 재조회할 수 있어야 합니다. + +### Commit 후 Response 유실 + +가장 위험한 케이스는 다음입니다. + +```text +Client + → COMMAND C42 + +Server + → APPLICATION_STARTED + → DB COMMIT + → Response 생성 + → Socket write 시작 + +Network + → 연결 단절 + +Client + → Response 미관측 +``` + +이때 Client의 올바른 상태는 `FAILED`가 아니라: + +```text +COMPLETION_UNKNOWN_TO_CLIENT +``` + +입니다. + +그리고 재연결 후: + +```text +COMMAND C42 재전송 + ↓ +Idempotency Ledger 조회 + ├─ COMPLETED + │ → 저장된 Result 반환 + │ + ├─ PROCESSING + │ → 진행 상태 반환 + │ + ├─ ABSENT + │ → 새 실행 + │ + └─ 동일 key + 다른 fingerprint + → conflict +``` + +으로 처리해야 합니다. + +따라서 상태 변경용 WebSocket Command에는 다음 중 하나가 필수입니다. + +```text +idempotencyKey +client-generated commandId +client-generated resourceId +reconciliation query +``` + +**Connection sequence만으로 mutation idempotency를 보장하면 안 됩니다.** Connection은 끊기고 다시 만들어질 수 있고, Resume window 역시 업무 idempotency TTL과 동일한 개념이 아니기 때문입니다. + +중요한 mutation이 이미 HTTP나 gRPC에서 명확하게 표현되고 있다면 다음 구조도 우선 검토할 가치가 있습니다. + +```text +HTTP/gRPC +→ Command 수행 + Idempotency + +WebSocket +→ 결과·상태 변경 Live Event 전달 +``` + +이렇게 하면 WebSocket이 mutation transport와 durable command ledger까지 모두 소유하는 복잡성을 크게 줄일 수 있습니다. + +### STOMP `RECEIPT`과 ACK의 정확한 의미 + +STOMP 1.2에서: + +- `RECEIPT`은 요청한 Frame에 대해 서버가 처리했다는 protocol-level 응답입니다. +- 이전 Frame들이 서버에 수신됐다는 누적 증거로는 사용할 수 있지만, 이전 모든 Frame의 최종 업무 처리를 보장하지 않습니다. +- `ack:auto`는 별도 Client ACK가 필요 없습니다. +- `ack:client`는 cumulative acknowledgment입니다. +- `ack:client-individual`은 개별 메시지 acknowledgment입니다. +- Connection이 ACK 전에 실패하면 서버가 메시지를 재전달할 수 있으나 구체적인 redelivery semantics는 서버 구현에 의존합니다. citeturn11view1turn11view2turn21search0 + +따라서 플랫폼 용어를 다음과 같이 분리해야 합니다. + +```text +TRANSPORT_WRITE +PROTOCOL_RECEIPT +BROKER_DELIVERY +BROKER_ACK +APPLICATION_COMMIT +CLIENT_RECEIVED +CLIENT_APPLIED +``` + +이 단어들을 서로 alias하지 않는 것이 중요합니다. + +### Ordering은 WebSocket Frame 순서와 Application 순서가 다르다 + +하나의 연결에서 네트워크 바이트의 순서가 유지되더라도, Spring STOMP의 `clientInboundChannel`과 `clientOutboundChannel`은 thread pool 기반으로 처리되므로 application handling과 publish 결과가 서로 다른 thread에서 수행되어 원래 순서와 달라질 수 있습니다. Spring은 `setPreserveReceiveOrder(true)`와 `setPreservePublishOrder(true)`를 제공하지만, 순서 보장에는 성능 비용이 있다고 명시합니다. citeturn20search1 + +따라서 다음 profile이 적절합니다. + +| Ordering Profile | 사용 예 | 계약 | +|---|---|---| +| `UNORDERED_LOW_LATENCY` | presence, typing | 순서 의존 금지 | +| `SESSION_ORDERED` | 한 connection command chain | session별 serialize | +| `SUBSCRIPTION_ORDERED` | subscription update | subscription별 sequence | +| `STREAM_KEY_ORDERED` | document/room/entity stream | logical stream별 sequence | + +특히 Multi-node와 Resume를 고려한다면 `sessionSequence`보다: + +```text +streamId + streamSequence +``` + +가 더 중요한 복구 기준입니다. + +예: + +```text +document:123 + seq=100 + seq=101 + seq=102 +``` + +Client는: + +```text +lastReceivedSequence +lastAppliedSequence +``` + +를 구분해야 합니다. + +`lastReceivedSequence=102`지만 UI/State Store에는 `101`까지만 반영하다 브라우저가 죽을 수도 있기 때문입니다. Resume 기준은 일반적으로 **`lastAppliedSequence`**가 더 안전합니다. + +### Subscription 계약 + +권장 Subscription request는 다음 정도면 충분합니다. + +```json +{ + "type": "subscribe", + "version": 1, + "messageId": "...", + "payload": { + "subscriptionId": "sub-01", + "topic": "document.changes", + "resourceId": "doc-123", + "resumeFrom": 101 + } +} +``` + +다음은 서버 등록 Catalog에 의해 통제합니다. + +```text +Topic Allowlist +Filter Allowlist +Projection Profile +Maximum subscriptions / connection +Maximum subscriptions / actor +Event rate +Outbound queue budget +Authorization profile +Resume capability +``` + +권한은 “Connection할 때 로그인했는가”와 “이 Event를 지금 받을 권리가 있는가”를 구분해야 합니다. Spring Security 역시 MESSAGE와 SUBSCRIBE의 Destination 권한을 구별하며, outbound 자체를 모두 검사하는 대신 subscription을 엄격히 보호하는 방식을 설명합니다. citeturn17search1 + +민감한 스트림에서 권한 회수가 즉시 반영되어야 한다면 다음 중 하나가 필요합니다. + +```text +Event delivery 시 Authorization 재검증 +또는 +Permission Revocation Event → subscription revoke / session close +또는 +짧은 Connection Max Age +``` + +모든 Event마다 DB Authorization Query를 수행하는 것은 비용이 크므로 Endpoint별 정책으로 두는 것이 좋습니다. + +### Reconnect와 Resume + +RFC 6455에는 끊어진 Application Stream의 replay cursor나 resume semantics가 정의돼 있지 않습니다. 끊어진 뒤에는 새 WebSocket Connection을 만들고 애플리케이션 protocol이 복구를 정의해야 합니다. citeturn7view2turn17search0 + +Stable Resume protocol은 다음 형태가 적절합니다. + +```text +Client disconnect + ↓ +Exponential Backoff + Jitter + ↓ +새 Handshake + 새 Authentication + ↓ +RESUME +{ + streamId, + resumeToken, + lastAppliedSequence, + snapshotVersion +} + ↓ +Server +├─ history available +│ → replay sequence+1 ... +│ +├─ history compacted / gap too old +│ → SNAPSHOT_REQUIRED +│ +├─ permission changed +│ → RESUME_DENIED +│ +└─ token expired + → RESUME_EXPIRED +``` + +Resume 성공 뒤에도 Client는 duplicate detection을 수행해야 합니다. + +```text +seq <= lastAppliedSequence +→ duplicate, ignore + +seq == lastAppliedSequence + 1 +→ apply + +seq > lastAppliedSequence + 1 +→ GAP, stop incremental apply +→ resume/snapshot request +``` + +Replay source는 WebSocket Node의 메모리가 아니라 **Messaging/Event Log 등 durable capability**여야 합니다. WebSocket Resume module은 cursor와 snapshot orchestration만 담당하는 것이 모듈 경계에 맞습니다. + +### Presence는 사실이 아니라 관측 결과다 + +WebSocket `OPEN`만 보고: + +```text +user.online = true +``` + +라고 업무 사실을 선언하면 안 됩니다. 네트워크 partition, background browser, delayed heartbeat, proxy timeout 때문에 실제 사용 상태와 Socket 관측 상태가 일치하지 않을 수 있기 때문입니다. + +권장 모델은: + +```text +lastObservedAt +activeConnectionCount +lastHeartbeatAt +presenceState = ONLINE | IDLE | STALE | OFFLINE +``` + +처럼 **관측 시각이 포함된 상태**입니다. + +Redis에는 Session 객체 자체가 아니라: + +```text +actor fingerprint +connection count +node id +lastObservedAt +TTL +``` + +정도의 summary만 저장하는 것이 적절합니다. + +## 런타임·Backpressure·Heartbeat·멀티인스턴스·STOMP 운영 + +### Servlet과 WebFlux 실행 모델 + +Servlet WebSocket에서는 동일 Session에 여러 thread가 동시에 write하는 구조를 피해야 합니다. Spring의 `ConcurrentWebSocketSessionDecorator`는 하나의 thread가 실제 send를 수행하도록 하고 send-time limit 및 buffer-size limit을 적용할 수 있습니다. Buffer overflow 처리 전략도 제공됩니다. citeturn12search0turn0search1 + +따라서 Servlet runtime은 다음 구조로 고정하는 것을 권장합니다. + +```text +Application Event + ↓ +SessionOutboundQueue + ↓ +Serialized Writer + ↓ +ConcurrentWebSocketSessionDecorator + ↓ +Container Session +``` + +```text +Application thread +→ WebSocketSession.sendMessage 직접 호출 +``` + +을 일반 API로 노출하지 않는 것이 좋습니다. + +WebFlux에서는 `WebSocketSession.receive()`가 inbound `Flux`를, `send(Publisher)`가 outbound 완료를 나타내는 reactive API를 제공합니다. Reactor Netty 등의 pooled buffer를 async boundary 뒤까지 보관할 경우 DataBuffer retain/release 수명도 고려해야 합니다. citeturn15view0turn4view3turn4view4 + +다만 **Reactive Streams를 쓴다는 이유만으로 브라우저까지 end-to-end backpressure가 자동 제공된다고 선언하면 안 됩니다.** 브라우저 WebSocket API에는 reactive demand protocol이 없으므로 서버의 bounded buffering과 message-level 정책은 여전히 필요합니다. 브라우저가 송신할 때는 `bufferedAmount`로 아직 network에 전달되지 않은 application data byte 수를 관찰할 수 있습니다. citeturn7view0 + +### Slow Consumer 정책 + +모든 message type에 같은 overflow policy를 적용하면 안 됩니다. + +| Message 성격 | Queue 초과 시 | +|---|---| +| 업무 Command 결과 | **Drop 금지**, disconnect + reconciliation/resume | +| 업무 상태 전이 Event | **Drop 금지**, disconnect + durable resume | +| Presence / Typing | `DROP` 또는 `COALESCE` 허용 | +| 최신 가격·상태 Snapshot | `COALESCE_BY_KEY` 가능 | +| Durable Event | local buffer 무한 확대 금지, connection close 후 cursor replay | +| Admin/security notice | 우선순위 Queue 또는 즉시 close | + +권장 overflow policy catalog: + +```text +DISCONNECT +DROP_LATEST +DROP_OLDEST +COALESCE_BY_KEY +SNAPSHOT_REQUIRED +``` + +`DROP_*`는 메시지 schema가 explicitly lossy라고 선언한 경우에만 허용해야 합니다. + +Spring STOMP도 client outbound가 느릴 때 한 thread가 실제 send를 하고 추가 메시지가 buffer에 쌓이는 구조이며 `sendTimeLimit`과 `sendBufferSizeLimit`을 제공하므로, 무제한 buffering을 피해야 합니다. Spring 문서는 `clientInboundChannel`과 `clientOutboundChannel`의 thread pool 및 queue 설정 또한 성능에 직접 영향을 준다고 설명합니다. citeturn20search0 + +### 초기 Resource Budget + +아래 값은 프로토콜 표준값이 아니라 **성능 시험을 시작하기 위한 Backend Skeleton 초기 profile 권고값**입니다. 서비스별 부하 시험 후 올리는 방식이 안전합니다. + +| 항목 | Stable 시작값 후보 | 비고 | +|---|---:|---| +| Raw JSON assembled message | 64 KiB | 더 큰 payload는 별도 Profile | +| Binary message | 256 KiB | Advanced | +| STOMP inbound message | 64 KiB | Spring STOMP client 기본 inbound limit도 64 KiB citeturn19search0 | +| JSON nesting depth | 32 | Codec guard | +| Array elements | 1,000 | Message schema가 더 낮게 설정 가능 | +| String bytes | 32 KiB | field별 더 낮은 제한 권장 | +| Subscriptions / connection | 32 | Profile별 조정 | +| In-flight requests | 32 | 무제한 correlation 금지 | +| Outbound queue | 512 KiB + message count limit | byte와 count 모두 제한 | +| Send stall limit | 10 s 시작값 | 실제 proxy/network 시험 필요 | +| Global buffered bytes | 반드시 상한 | Heap 보호 | +| Connection / actor | endpoint별 명시 | browser multi-tab 고려 | +| Reconnect rate | actor·IP·tenant별 제한 | reconnect storm 방지 | + +여기서 **Frame size, assembled WebSocket Message size, STOMP Message size, decoded JSON size, outbound queue size는 각각 별도 제한**이어야 합니다. Spring STOMP 자체도 WebSocket message를 조립해 더 큰 STOMP message를 구성할 수 있으며 이를 위해 별도의 message size limit을 제공합니다. citeturn20search0turn19search0 + +### Heartbeat 계층을 분리해야 한다 + +다음은 서로 같은 기능이 아닙니다. + +```text +TCP Keepalive +WebSocket Ping/Pong +STOMP Heartbeat +Application Heartbeat +Nginx proxy_read_timeout +Presence TTL +``` + +RFC 6455에서 Ping을 받은 endpoint는 closing 상태가 아니라면 Pong으로 응답해야 하며 Ping/Pong은 keepalive와 peer responsiveness 확인에 사용할 수 있습니다. citeturn8view3 + +STOMP heartbeat는 `CONNECT`와 `CONNECTED`의 `heart-beat` 값으로 양측 송신 능력과 수신 희망 간격을 교환하고 각 방향의 실제 최소 간격을 계산합니다. citeturn21search0 + +Nginx 공식 WebSocket proxy 문서는 upstream server가 아무 데이터도 보내지 않으면 기본적으로 60초 후 연결을 종료한다고 설명하며, `proxy_read_timeout`을 늘리거나 WebSocket Ping을 주기적으로 보내 연결 활동과 생존 확인을 수행할 수 있다고 명시합니다. citeturn17search2turn16view1 + +따라서 설정 관계를 다음처럼 계약화해야 합니다. + +```text +heartbeatInterval + < heartbeatTimeout + < proxyReadTimeout +``` + +예를 들어 실제 Profile을 `25s heartbeat / 55s failure / 75s proxy read`처럼 잡을 수 있지만, 구체 숫자는 Nginx·Ingress·LB·모바일 네트워크 시험 결과로 결정하는 편이 좋습니다. + +Application heartbeat는 transport heartbeat와 별도로 정말 필요한 경우만 사용합니다. 예를 들어 “Client app event loop가 정상적으로 state를 적용하고 있는지”가 중요하면 app-level `PING/PONG` 또는 state watermark를 별도 정의할 수 있습니다. + +### Browser outbound budget + +브라우저의 `WebSocket.bufferedAmount`는 `send()`한 데이터 중 아직 network로 전달되지 않은 byte 수를 표시하므로 Client SDK의 로컬 backpressure 신호로 유용합니다. 다만 이것은 상대 서버가 메시지를 받았다는 ACK가 아닙니다. citeturn7view0 + +Client SDK는 다음을 가져야 합니다. + +```text +maxBufferedAmount +bounded command queue +message priority +expiresAt +retryability +idempotency requirement +offline queue policy +``` + +특히 offline 상태에서 mutation을 무제한 저장한 뒤 재접속 시 전부 보내면 오래된 Command가 뒤늦게 실행될 수 있으므로: + +```text +expiresAt ++ +idempotencyKey ++ +explicit offline-capable flag +``` + +가 필요합니다. + +### Compression + +RFC 7692 `permessage-deflate`는 Opening Handshake에서 협상하는 per-message compression extension입니다. `server_no_context_takeover`, `client_no_context_takeover`, `server_max_window_bits`, `client_max_window_bits` 등의 파라미터로 양 방향 압축 context와 memory footprint를 제어할 수 있습니다. citeturn22search0 + +따라서 Stable 기본은: + +```text +permessage-deflate = OFF +``` + +가 적절하고 다음을 측정한 Endpoint만 Opt-in하는 것이 좋습니다. + +```text +bandwidth 절감률 +CPU / connection +memory / connection +p95/p99 send latency +decompressed size +compression context memory +sensitive data + attacker-controlled input 위험 +``` + +Binary Protobuf처럼 이미 compact한 payload는 compression 효율이 낮을 수 있으므로 Codec별 benchmark가 필요합니다. + +### Multi-instance Session 구조 + +실제 socket은 연결을 받은 application instance가 소유하므로 구조를 다음처럼 나누는 것이 좋습니다. + +```text +Node A +├─ actual WebSocketSession +├─ local subscription handlers +├─ local outbound queue +└─ local writer + +External Registry +├─ connectionId → nodeId +├─ actor → active node summaries +├─ subscription summary +└─ TTL / lastObservedAt + +Fan-out Capability +├─ ephemeral: Redis capability +└─ durable: Messaging capability +``` + +**Native Session 객체를 Redis에 직렬화하여 다른 Node로 이동시키는 구조는 금지**해야 합니다. + +Sticky session도 다음 문제를 해결하지 못합니다. + +```text +Pod restart +Node crash +Deployment +Reconnect +Resume history +Lost event +``` + +따라서 sticky routing은 최적화일 수 있어도 recovery contract가 되어서는 안 됩니다. + +### STOMP 지원 범위 + +Spring의 Simple Broker는 시작하기 쉬우나 STOMP 명령의 subset만 지원하고 ACK·RECEIPT 등을 지원하지 않으며 clustering에 적합하지 않습니다. Spring은 production-scale broadcast를 위해 external broker relay를 별도 옵션으로 제공합니다. citeturn19search1turn20search0 + +| 기능 | Simple Broker | Broker Relay | +|---|---|---| +| Local Pub/Sub | 지원 | 지원 | +| `SEND`·`SUBSCRIBE` | 기본 지원 | Broker 지원 범위 | +| ACK | 제한/비지원 | Broker capability | +| RECEIPT | 제한/비지원 | Broker capability | +| Cluster | 부적합 | 가능 | +| Durable queue | 보장하지 않음 | Broker·Destination 설정에 따라 | +| Redelivery | 보장하지 않음 | Broker 설정에 따라 | +| DLQ | 없음 | Broker capability | +| Transaction | 제한 | Broker capability | +| User Destination | Spring 변환 가능 | Broker와 결합 검증 | +| 운영 권장 | Local/Test·단일 node | Advanced production | + +Spring Broker Relay는 애플리케이션과 외부 Broker 사이에서 TCP 연결을 사용해 메시지를 양 방향 relay합니다. 따라서 Broker Relay를 채택할 때는 WebSocket socket 수뿐 아니라 Broker connection footprint, broker failover, heartbeat, reconnection, broker-side destination lifecycle을 별도로 부하 시험해야 합니다. citeturn19search1 + +Multi-server User Destination은 Spring의 user-destination/registry broadcast 기능을 이용해 다른 application server에 연결된 사용자를 찾는 구성이 가능하지만, 이 역시 Broker의 temporary queue 정리 및 destination semantics와 함께 검증해야 합니다. citeturn3search7turn3search3 + +## 오류·Close·Proxy·Shutdown·관측성 계약 + +### Error Message와 Close는 분리 + +Message 단위 오류가 발생했다고 항상 Connection을 끊는 것은 좋지 않습니다. + +```text +Recoverable message error +→ ERROR message +→ Connection 유지 + +Connection-scoped fatal error +→ Close +``` + +가 기본 원칙이어야 합니다. + +RFC/IANA WebSocket Close code 기준에서 주요 코드는 다음과 같습니다. citeturn9view2turn9view3turn8view0turn9view4turn21search1 + +| Close Code | 의미 | 플랫폼 사용 | +|---:|---|---| +| `1000` | Normal Closure | 정상 종료 | +| `1002` | Protocol Error | frame/subprotocol 위반 | +| `1003` | Unsupported Data | 지원하지 않는 data type | +| `1007` | Invalid Payload Data | invalid UTF-8 등 | +| `1008` | Policy Violation | 일반 protocol/security policy | +| `1009` | Message Too Big | size limit | +| `1011` | Internal Error | 예기치 못한 server failure | +| `1012` | Service Restart | rolling restart/drain | +| `1013` | Try Again Later | 일시 과부하 | +| `4000–4999` | Private Use | application close catalog | + +IANA registry는 `1012`를 Service Restart, `1013`을 Try Again Later로 등록하고 `4000–4999`를 Private Use 범위로 둡니다. citeturn21search1turn21search3 + +권장 private catalog는 다음과 같습니다. + +```text +4400 INVALID_MESSAGE +4401 AUTHENTICATION_REQUIRED +4403 ACCESS_DENIED +4408 HEARTBEAT_TIMEOUT +4409 DUPLICATE_CONNECTION +4422 VALIDATION_FAILED +4429 RATE_LIMITED +4503 OVERLOADED +``` + +다만 `VALIDATION_FAILED` 같은 Message 단위 오류는 일반적으로 Close보다 Typed `ERROR`가 우선입니다. Private Close는 “이 Connection을 더 이상 유지할 수 없는 이유”에 사용해야 합니다. + +Browser WebSocket API에서 script가 직접 `close()`에 지정할 수 있는 code는 `1000` 또는 `3000–4999` 범위이며, reason은 UTF-8 기준 123 bytes 이하여야 합니다. 따라서 Client-visible close reason에는 stack trace·SQL·token·PII를 넣으면 안 됩니다. citeturn7view0 + +### Nginx와 TLS + +Nginx reverse proxy에서 `Upgrade`와 `Connection`은 hop-by-hop header이므로 upstream으로 자동 전달되지 않으며 WebSocket proxying을 위해 명시적으로 처리해야 합니다. Nginx 공식 구성도 `Upgrade`와 `Connection`을 별도로 설정합니다. citeturn17search2 + +기본 계약은 다음처럼 두는 것이 좋습니다. + +```nginx +location /ws/ { + proxy_pass http://backend; + + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + + # deployment profile에 맞춰 명시 + proxy_read_timeout ...; + proxy_send_timeout ...; +} +``` + +Nginx 최신 문서 기준으로 `proxy_read_timeout` 기본값은 60초이며 “전체 응답 시간”이 아니라 두 successive read 사이 timeout입니다. WebSocket처럼 idle할 수 있는 장기 연결에서는 반드시 heartbeat profile과 정렬해야 합니다. citeturn16view0turn16view1 + +Forwarded header와 외부 URL 신뢰 모델은 `web` 모듈에서 이미 정한 정책을 재사용해야 합니다. + +```text +Client +→ Host Nginx + → untrusted Forwarded 제거 + → trusted X-Forwarded-* 재작성 +→ Backend +→ web platform normalization +→ WebSocket handshake context +``` + +WebSocket이 독자적인 `X-Forwarded-For` parsing 로직을 만들면 `web` 플랫폼과 client IP·scheme·host 결과가 달라질 수 있습니다. + +TLS 정책은 다음이 적절합니다. + +```text +Local +→ ws 허용 + +Dev / Staging / Prod +→ wss 필수 + +TLS termination +→ Nginx 또는 trusted ingress + +Backend plaintext +→ trusted internal network profile에서만 + +Origin / External Host +→ normalized web context 사용 +``` + +### Graceful Shutdown + +Spring Boot는 servlet/reactive server의 graceful shutdown을 제공하지만, **“Client가 어떤 sequence부터 다시 받아야 하는지”나 “어떤 WebSocket Command를 이제 받지 말아야 하는지”는 application protocol의 책임**입니다. 따라서 WebSocket 플랫폼은 별도의 drain state machine을 가져야 합니다. citeturn0search7 + +권장 흐름: + +```text +Readiness OFF +→ 신규 Handshake Admission 차단 +→ 기존 Connection 상태 DRAINING +→ SERVER_DRAINING Control Message + { + reconnectAfter, + resumeSupported, + deadline + } +→ 신규 Subscription 거부 +→ 신규 mutation Command 거부 +→ 이미 commit 중인 Command 제한 시간 처리 +→ Outbound Queue 제한 시간 drain +→ Close 1012 Service Restart +→ shutdown deadline 초과 시 강제 close +``` + +Connection이 무기한 지속되는 것을 허용하기보다: + +```text +maxConnectionAge +credentialExpiresAt +serverDrainDeadline +resumeWindow +``` + +를 두는 편이 Rolling Update 운영에 유리합니다. + +### Outbound WebSocket Client + +Backend가 외부 WebSocket provider와 연결하는 기능은 일반 `httpclient` retry profile을 그대로 사용하지 않는 것이 맞습니다. WebSocket client는 “RPC 재시도”가 아니라 **Connection 재수립 + Protocol 재협상 + Subscription 재등록 + Resume** 문제이기 때문입니다. + +Spring WebFlux WebSocket client는 Reactor Netty, Tomcat, Jetty, 표준 Java WebSocket client 구현을 지원합니다. citeturn15view0 + +권장 Named profile: + +```yaml +websocket: + clients: + market-feed: + uri: wss://provider.example/stream + protocol: provider.feed.v2 + tls: provider-ca + connect-timeout: 5s + heartbeat: 20s + idle-timeout: 60s + max-message-size: 64KiB + max-buffered-bytes: 512KiB + reconnect: + min-backoff: 500ms + max-backoff: 30s + jitter: true + resume: + supported: true +``` + +Profile에는 최소: + +```text +URI +TLS +Proxy +Subprotocol +Authentication +Handshake timeout +Heartbeat +Idle timeout +Max age +Message limits +Reconnect strategy +Resume strategy +Observability +``` + +를 포함해야 합니다. + +### 관측성 모델 + +장기 연결 하나에 거대한 tracing span 하나를 유지하기보다 **Handshake span + message operation span + connection metric** 구조가 운영상 더 적합합니다. + +권장 Metric: + +| 영역 | Metric | +|---|---| +| Connection | active, opened, rejected, closed, duration, abnormal close | +| Heartbeat | ping, pong, timeout, suspected half-open | +| Reconnect | attempt, success, resume success/failure | +| Message | inbound/outbound count·bytes | +| Validation | schema failure, unknown type/version | +| Security | authentication/authorization reject | +| Application | started, committed, failed | +| Ordering | duplicate, sequence gap | +| Backpressure | queue bytes/messages, slow consumer, drop, coalesce | +| Subscription | active, rejected, event lag | +| STOMP | broker availability, receipt timeout, relay disconnect | + +허용 tag: + +```text +endpointProfile +protocol +protocolVersion +messageTypeCatalog +operationCatalog +closeCode +outcome +node +resumeOutcome +``` + +금지 tag: + +```text +sessionId +connectionId +userId +tenantId raw +resourceId +messageId +subscriptionId +destination의 동적 부분 +token +payload +``` + +Connection ID 같은 값은 로그 필드나 trace correlation에 제한적으로 사용할 수 있어도 Metric tag로 사용하면 cardinality가 폭증하므로 금지하는 것이 좋습니다. + +Access log/event log 예: + +```text +timestamp +connectionFingerprint +actorFingerprint +endpointProfile +protocol +node +event = OPEN | CLOSE | RESUME | DRAIN +closeCode +duration +bytesIn +bytesOut +``` + +Admin plane은: + +```text +Connection Summary +Protocol Version Usage +Node별 active count +Slow Consumer count +Close Code distribution +Resume failure +Broker status +``` + +를 제공하되 payload/token/raw filters는 노출하지 않는 것이 좋습니다. + +다음 관리 작업은 모두 Audit 대상입니다. + +```text +Session Disconnect +Actor Session Disconnect +Tenant Drain +Endpoint Drain +Protocol Version Disable +Maintenance Broadcast +Resume State 수동 무효화 +``` + +## 지원 등급·테스트 전략·구현 로드맵 + +### 최종 기능 지원 매트릭스 + +| Capability | 최종 권고 등급 | 완료 조건 | +|---|---|---| +| Raw JSON Typed WebSocket | **Stable** | Servlet·WebFlux contract + browser/proxy test | +| Request–Response | **Stable** | correlation, timeout, cancellation, late response 정의 | +| Mutation Command | Stable 조건부 | Idempotency ledger 필수 | +| Typed Event | **Stable** | bounded queue + schema catalog | +| Server heartbeat | **Stable** | proxy timeout E2E 검증 | +| Exact Origin policy | **Stable 필수** | browser CSWSH test | +| Cookie/session auth | **Stable** | Origin + security integration | +| One-time ticket auth | **Stable 권장** | atomic single-use + TTL | +| Connection max age | **Stable** | reconnect/drain test | +| Session serialized writer | **Stable 필수** | concurrency stress test | +| Bounded outbound queue | **Stable 필수** | slow-consumer test | +| Sequence / Gap detection | **Stable for ordered streams** | duplicate/gap contract | +| Resume | **Advanced** | durable replay source 필요 | +| Snapshot fallback | **Advanced** | history-lost test | +| Subscription | **Advanced** | auth·limits·ordering 정의 | +| Application ACK | Advanced | 의미 명시 + ledger 필요 여부 결정 | +| STOMP 1.2 | **Advanced Stable** | protocol matrix | +| Simple Broker | Local/Test | cluster 사용 금지 | +| Broker Relay | Advanced | real broker fault test | +| Multi-node fan-out | Advanced | cross-node integration test | +| Presence | Advanced | TTL·stale semantics | +| Protobuf Binary | Advanced | generated client compatibility | +| CBOR | Advanced | 실제 client 수요가 있을 때 | +| `permessage-deflate` | Advanced Opt-in | CPU/memory/security benchmark | +| Outbound WS client | Advanced | reconnect/resume profile | +| SockJS | Legacy | 명시적 legacy requirement | +| HTTP/2 WS | Compatibility | end-to-end matrix | +| HTTP/3 WS | Experimental | end-to-end support 검증 | +| GraphQL WS semantics | **WebSocket에서 비소유** | GraphQL adapter만 | +| Durable Replay/DLQ | **비지원** | Messaging 사용 | +| Large file | **비지원** | Fileserver 사용 | +| Java serialization | **비지원** | — | +| WebSocket exactly-once | **비지원 선언** | idempotent business operation으로 대체 | + +### 핵심 Contract Test + +Mock WebSocketSession만으로 Stable을 선언해서는 안 됩니다. Spring의 실제 Servlet/WebFlux runtime, Nginx, TLS, browser를 모두 거쳐야 low-level connection semantics를 검증할 수 있습니다. Spring 자체도 Servlet WebSocket과 Reactive WebSocket의 실행 API가 다르고, STOMP에서는 별도 thread pools·buffers·broker relay가 개입합니다. citeturn17search0turn20search0turn15view0 + +**Handshake·Security** + +```text +101 정상 연결 +malformed handshake +unsupported subprotocol +missing subprotocol +Origin allowed / rejected +null Origin +Cookie session +expired session +one-time ticket success +ticket replay +ticket expiration +STOMP CONNECT token +CSRF CONNECT +Forwarded header spoof +Host spoof +connection rate limit +draining endpoint +``` + +Spring Security가 WebSocket에서 Same-Origin 방어와 STOMP CONNECT CSRF를 별도로 강조하므로 이 테스트는 Release Gate에 포함해야 합니다. citeturn17search1 + +**Message·Schema** + +```text +normal JSON +binary +fragmentation +invalid UTF-8 +malformed JSON +unknown message type +unknown schema version +unknown enum +oversized string +oversized array +deep JSON +assembled size overflow +compression +decompressed oversize +``` + +**Execution Evidence·Idempotency** + +```text +FRAME_RECEIVED 전 disconnect +MESSAGE_VALIDATED 후 reject +APPLICATION_STARTED 후 failure +APPLICATION_COMMITTED 직후 socket reset +response queue 전 disconnect +write 시작 후 disconnect +동일 commandId 재전송 +동일 idempotencyKey + 동일 fingerprint +동일 idempotencyKey + 다른 fingerprint +ledger PROCESSING 상태 crash +commit 후 reconnect + reconciliation +``` + +이 테스트가 플랫폼의 핵심 질문에 가장 직접적으로 답합니다. + +**Ordering** + +```text +동일 Session 병렬 inbound +동일 Subscription 병렬 event +preserveReceiveOrder off/on +preservePublishOrder off/on +cross-node event +duplicate sequence +missing sequence +out-of-order sequence +reconnect 경계 sequence +``` + +Spring STOMP의 ordering option이 기본 thread-pool reorder를 보완하는 기능이므로 해당 설정의 비용과 효과를 실제 throughput test에서 비교해야 합니다. citeturn20search1 + +**Backpressure** + +```text +slow browser +blocked network +queue bytes cap +queue message cap +send time cap +DROP_LATEST +DROP_OLDEST +COALESCE_BY_KEY +critical message overflow +global buffer exhaustion +browser bufferedAmount 증가 +WebFlux slow subscriber +``` + +Spring STOMP의 outbound send도 slow client에서 buffer가 증가할 수 있어 send-time과 buffer-size limit이 별도로 제공됩니다. citeturn20search0 + +**Heartbeat·Network** + +```text +Ping/Pong 정상 +Pong 손실 +Server Ping 정지 +half-open +Nginx proxy_read_timeout +TCP reset +mobile network switch +browser sleep +background tab +temporary packet loss +TLS termination restart +``` + +Nginx 기본 60초 idle timeout과 WebSocket Ping 사용 가능성을 실제 배포 설정에 맞춰 검증해야 합니다. citeturn17search2 + +**Resume** + +```text +normal resume +new node resume +lastAppliedSequence 정상 +duplicate event +sequence gap +history compacted +snapshot fallback +resume token expired +resume token replay +permission revoked +schema version changed +stream deleted +``` + +**Multi-instance** + +```text +Node A client connection +Node B business event +A로 cross-node fan-out +Node A kill -9 +Node C reconnect +registry TTL cleanup +stale registry entry +duplicate session registration +network partition between app and fan-out +``` + +**STOMP** + +```text +CONNECT / CONNECTED +heartbeat negotiation +SEND +SUBSCRIBE / UNSUBSCRIBE +ACK auto +ACK client +ACK client-individual +NACK +RECEIPT +ERROR +broker disconnect +broker reconnect +simple broker limitation +external relay +user destination multi-node +ordered publication +``` + +STOMP ACK mode와 redelivery 의미는 규격 및 Broker별 capability를 함께 검증해야 합니다. citeturn21search0turn19search1 + +### 장애·보안·성능 Gate + +성능 시험에서는 단순 messages/sec 하나만 보지 말고 다음을 함께 측정해야 합니다. + +```text +Concurrent connections / node +Handshake RPS +Reconnect RPS +Idle connection heap +Idle connection direct memory +Thread count +Event-loop utilization +Inbound messages/sec +Outbound messages/sec +p50 / p95 / p99 message latency +queue bytes / connection +global buffered bytes +slow consumer ratio별 처리량 +serialization CPU +compression CPU +broker relay latency +resume replay throughput +snapshot latency +GC pause +connection drain duration +``` + +특히 다음 부하 시나리오가 중요합니다. + +```text +정상 Client 100% +slow Client 1% +slow Client 10% +slow Client 50% + +동시에: +Node restart +Broker latency +Redis latency +Reconnect storm +``` + +Slow client 몇 개 때문에 전체 outbound thread pool이나 direct memory가 고갈되지 않는지 검증해야 합니다. Spring도 outbound 성능이 client network speed에 크게 영향을 받고 별도의 send/buffer limit이 필요하다고 설명합니다. citeturn20search0 + +Browser matrix는 최소: + +```text +Chromium +Firefox +WebKit + +Foreground +Background tab +Sleep / wake +Offline / online +Wi-Fi ↔ Mobile network +Browser close +Page navigation +``` + +까지 포함하는 것이 좋습니다. + +Runtime matrix: + +```text +Tomcat + Nginx + TLS +Jetty + Nginx + TLS +Reactor Netty + Nginx + TLS +``` + +를 Stable gate로 잡고, HTTP/2·HTTP/3는 별도 compatibility lane에서 검증합니다. Boot 4.1의 공식 servlet container 기준은 Tomcat 11.0.x와 Jetty 12.1.x이며 reactive server는 Reactor Netty·Tomcat·Jetty를 지원합니다. citeturn18search1turn18search3 + +### 단계별 구현 순서와 완료 조건 + +**기준선·경계 확정** + +먼저 `websocket-core-api`, MVC/WebFlux starter의 상호 배타성, `web`·`security`·`messaging`·`redis`와의 의존 방향을 확정합니다. + +완료 조건: + +```text +Boot 4.1 BOM +Java 21 +Tomcat / Reactor Netty 기본 profile +인접 모듈 dependency rule +금지 API architecture test +``` + +**Raw Typed Stable Runtime** + +다음으로 STOMP 없이 Raw JSON부터 완성하는 것이 좋습니다. + +```text +Handshake +Origin +Subprotocol +Connection Context +JSON Envelope +Message Catalog +Request–Response +Error +Serialized Writer +Size Limit +Heartbeat +Observability +``` + +완료 조건은 실제 Chromium + Nginx + Tomcat/Reactor Netty에서 기본 시나리오가 통과하는 것입니다. + +**실행 증거와 상태 변경 안전성** + +플랫폼의 가장 중요한 단계입니다. + +```text +Application Started evidence +Commit evidence abstraction +Idempotency capability bridge +Result ledger +Completion Unknown +Reconciliation +``` + +완료 조건: + +```text +DB Commit 직후 Network reset +→ Client retry +→ business mutation은 1회 +→ 이전 Result 회수 가능 +``` + +이 시나리오가 자동화 테스트로 증명되어야 합니다. + +**Backpressure·Ordering·Resource Budget** + +```text +Bounded queue +Serialized writer +Slow consumer classification +Sequence +Gap detection +Ordering profile +Global buffer admission +Browser bufferedAmount policy +``` + +완료 조건은 slow-client stress 중에도 fast-client p99와 server memory가 설정된 범위에서 유지되고, critical event drop이 발생하지 않는 것입니다. + +**Reconnect·Resume** + +```text +resumeToken +lastAppliedSequence +durable event cursor +deduplication +gap detection +snapshot fallback +``` + +완료 조건은 Node A 강제 종료 후 Node B/C에 reconnect해도 중복 없이 최신 state로 수렴하는 것입니다. + +**Multi-node·STOMP** + +Raw protocol의 cluster path를 먼저 검증한 뒤 STOMP adapter를 붙이는 것이 좋습니다. + +```text +External Session Index +Fan-out adapter +STOMP 1.2 +Simple Broker local profile +External Broker Relay +User Destination +Broker outage +``` + +Spring Simple Broker는 clustering에 적합하지 않으므로 multi-node Stable 여부는 external fan-out 또는 Broker Relay 시험으로 판단해야 합니다. citeturn19search1turn20search0 + +**고급·호환 기능** + +마지막에 다음을 추가합니다. + +```text +Protobuf +CBOR +permessage-deflate +Outbound WebSocket Client +SockJS +HTTP/2 WebSocket +HTTP/3 WebSocket +Admin Plane +``` + +각 기능은 기본 Starter를 비대하게 만들지 않고 별도 module/profile로 승격합니다. Compression은 RFC 7692 협상·memory control과 실제 CPU/heap benchmark가 완료된 Endpoint만 활성화해야 합니다. citeturn22search0 + +### 최종 플랫폼 계약 + +이번 조사 결과를 가장 압축해서 표현하면 다음과 같습니다. + +```text +WebSocket Runtime이 보장하는 것 += +연결 수명 ++ 인증된 Connection Context ++ Typed Protocol 진입 ++ bounded resource usage ++ serialized outbound write ++ heartbeat / disconnect ++ execution evidence 관측 ++ reconnect / resume orchestration +``` + +그러나 다음은 보장하지 않습니다. + +```text +WebSocket Frame 전송 +≠ Business Commit + +Business Commit +≠ Response Delivery + +Response Delivery +≠ Client Applied + +Connection Sequence +≠ Business Idempotency + +STOMP RECEIPT +≠ Transaction Commit + +STOMP ACK +≠ 보편적인 Durable Exactly-once + +WebSocket Reconnect +≠ Stream Resume + +Simple Broker +≠ Clustered Durable Broker + +Presence OPEN +≠ 사용자가 실제 Online이라는 절대 사실 +``` + +WebSocket 자체와 Spring의 low-level API가 제공하지 않는 이 의미들을 플랫폼이 명시적으로 분리해야 합니다. WebSocket은 content semantics를 규정하지 않는 transport이고, STOMP 역시 Destination과 reliability의 실제 의미를 server implementation에 맡기며, Spring Simple Broker 또한 ACK·Receipt와 clustering에 한계가 있습니다. citeturn17search0turn21search0turn19search1 + +따라서 최종 권고 구조는 다음입니다. + +```text + ┌───────────────────────┐ + │ HTTP / Web │ + │ Handshake, Proxy, Auth │ + └───────────┬───────────┘ + │ 101 + ▼ +┌─────────────────────────────────────────────────────┐ +│ WebSocket Platform │ +│ │ +│ Connection Context │ Session │ Budget │ Heartbeat │ +│ Security │ Ordering │ Backpressure │ Evidence │ +│ Observability │ Drain │ Reconnect Coordination │ +└───────────────┬───────────────────┬─────────────────┘ + │ │ + ┌────────▼────────┐ ┌──────▼─────────┐ + │ Raw Typed JSON │ │ STOMP Adapter │ + │ / Protobuf │ │ Broker Relay │ + └────────┬────────┘ └──────┬─────────┘ + │ │ + └────────┬──────────┘ + ▼ + Application Use Case + │ + ┌──────────────┼──────────────┐ + ▼ ▼ ▼ + JPA/Mongo Messaging Redis + Commit Replay/DLQ Registry/TTL + │ │ + └───────┬──────┘ + ▼ + Durable Execution Evidence + │ + ▼ + WebSocket Live Delivery +``` + +이 모델에서 **WebSocket은 “실시간 전달”을 소유하고, Application은 “상태 변경의 진실”을 소유하며, Messaging은 “내구성 있는 이벤트 이력”을 소유합니다.** 그 경계가 지켜져야 `APPLICATION_COMMITTED`, `RESPONSE_NOT_OBSERVED`, `CLIENT_APPLIED`, `RESUME_FROM_SEQUENCE`를 서로 혼동하지 않고 질문하신 핵심 문제—“서버에 도착했는가, 커밋됐는가, 프레임이 나갔는가, 클라이언트가 적용했는가, 어디부터 재개할 수 있는가”—에 각각 독립적인 증거로 답할 수 있습니다. diff --git a/docs/websocket-superpowers-package/research/source-websocket-deep-research.md b/docs/websocket-superpowers-package/research/source-websocket-deep-research.md new file mode 100644 index 00000000..5be010bd --- /dev/null +++ b/docs/websocket-superpowers-package/research/source-websocket-deep-research.md @@ -0,0 +1,1770 @@ +# WebSocket 실시간 양방향 연결 실행 플랫폼 심층 리서치 + +## 결론과 기술 기준선 + +이번 조사에서 가장 중요한 결론은 **WebSocket 모듈의 안정성을 “연결이 살아 있고 `sendMessage()`가 성공했는가”로 정의해서는 안 된다**는 것입니다. RFC 6455 WebSocket은 HTTP Upgrade 이후 양방향 메시지를 운반하는 저수준 프로토콜이고, 애플리케이션 메시지의 라우팅·업무 처리 완료·구독·ACK·재전송·Resume 의미를 정의하지 않습니다. Spring Framework 역시 WebSocket 자체는 메시지 내용의 의미를 정의하지 않으므로 STOMP 같은 subprotocol을 협상하거나 애플리케이션 규약을 별도로 만들어야 한다고 설명합니다. citeturn17search0turn7view2 + +따라서 권장 모델은 제시하신 **접근 C, `공통 Connection Runtime + Protocol Adapter`**입니다. + +```text +HTTP Handshake / Upgrade + ↓ +WebSocket Connection Runtime +├─ Origin / Authentication / Admission +├─ Connection Context +├─ Local Session Registry +├─ Inbound Assembly / Budget +├─ Outbound Serialized Writer +├─ Bounded Queue / Backpressure +├─ Heartbeat / Idle / Max Age +├─ Security / Observability +├─ Drain / Disconnect +└─ Reconnect Coordination + ↓ +Protocol Adapter +├─ Raw Typed Protocol +├─ STOMP 1.2 +├─ GraphQL Subscription Bridge +└─ Provider-specific Protocol + ↓ +Protocol Command / Query / Event + ↓ +Application Use Case + ↓ +JPA / MongoDB / Messaging / Redis / HTTP Client +``` + +이 구조의 핵심은 **Connection Runtime의 운영 의미와 Protocol Adapter의 메시지 의미를 분리하는 것**입니다. STOMP의 `ACK`, `RECEIPT`, Destination 의미를 Raw Typed Protocol에 억지로 투영해서도 안 되고, 반대로 자체 Raw Protocol의 `messageId`, `sequence`, `resumeToken`을 STOMP 표준 기능인 것처럼 선언해서도 안 됩니다. STOMP 자체도 Destination 문자열을 opaque한 값으로 취급하며, 실제 전달·신뢰성 의미는 서버와 Destination 구현에 따라 달라진다고 명시합니다. citeturn21search0 + +### 현재 기술 기준 + +2026년 8월 14일 기준 Spring Boot 문서의 Stable은 `4.1.0`이며, Boot 4.1.0은 Spring Framework `7.0.8+`를 요구합니다. Java 최소 요구는 17이고 Java 26까지 호환되므로, Backend Skeleton이 Java 21을 자체 기준선으로 고정하는 것은 충분히 합리적인 플랫폼 정책입니다. Embedded Servlet Container 기준으로 Boot 4.1.0은 Tomcat 11.0.x와 Jetty 12.1.x를 지원합니다. citeturn18search1 + +Spring Boot 4.1은 embedded Tomcat과 Jetty의 WebSocket 자동 구성을 제공하고 MVC 애플리케이션에서는 `spring-boot-starter-websocket`을 제공하며, reactive 애플리케이션은 WebSocket API와 `spring-boot-starter-webflux` 조합을 사용합니다. Spring Boot의 reactive server 지원 범위에는 Reactor Netty, Tomcat, Jetty가 있으며, WebFlux 쪽 기본 운영 후보는 Reactor Netty가 적절합니다. citeturn18search0turn18search3 + +| 영역 | 조사 결론 | 플랫폼 등급 | +|---|---|---| +| Java | **21 기준선**. Spring 최소값보다 플랫폼 기준을 높게 고정 | Stable | +| Spring Boot | **4.1 BOM** | Stable | +| Spring Framework | Boot 관리 `7.0.x`, 현재 Boot 최소 `7.0.8` | Stable | +| Servlet Raw WebSocket | Tomcat 기본, Jetty 호환 Lane | Stable | +| Reactive WebSocket | WebFlux + Reactor Netty 기본 | Stable 선택 | +| Raw Text Protocol | UTF-8 JSON Typed Envelope | **Stable 기본** | +| Raw Binary | Protobuf 우선 검토, CBOR 선택 | Advanced | +| STOMP | STOMP 1.2 Adapter | Advanced Stable | +| Simple Broker | Local/Test·단일 인스턴스 제한 | 제한 지원 | +| External Broker Relay | Broker capability 검증 후 | Advanced | +| SockJS | 신규 서비스 기본 제외 | Legacy Compatibility | +| `permessage-deflate` | Endpoint별 명시적 Opt-in | Advanced | +| HTTP/2 WebSocket | RFC 8441 경로별 E2E 검증 | Compatibility | +| HTTP/3 WebSocket | RFC 9220은 표준이 존재하나 플랫폼 채택은 별도 | Experimental | +| 대형 파일 전송 | Fileserver/Object Storage 사용 | 비지원 | +| Durable ACK·DLQ·Replay | Messaging 소유 | WebSocket 비지원 | + +HTTP/2와 HTTP/3에서 WebSocket을 구성하는 표준 자체는 각각 RFC 8441과 RFC 9220으로 이미 존재합니다. 따라서 “HTTP/3 WebSocket 프로토콜이 실험적”이라고 표현하기보다는, **표준은 존재하지만 Backend Skeleton에서 Client–Nginx/Ingress–Runtime 전체 경로 검증이 끝나지 않았으므로 플랫폼 기능 등급을 Experimental로 둔다**고 표현하는 것이 정확합니다. RFC 9220은 HTTP/3의 Extended CONNECT를 WebSocket에 적용합니다. citeturn22search1turn7view3 + +### 핵심 질문에 대한 답 + +한 메시지의 실행 상태는 다음 한 줄로 표현해서는 안 됩니다. + +```text +DELIVERED = true / false +``` + +대신 적어도 세 증거 축이 필요합니다. + +```text +Inbound Evidence +FRAME_RECEIVED +→ MESSAGE_ASSEMBLED +→ MESSAGE_VALIDATED +→ MESSAGE_AUTHORIZED +→ APPLICATION_STARTED +→ APPLICATION_COMMITTED | APPLICATION_FAILED + +Outbound Evidence +MESSAGE_CREATED +→ QUEUED +→ WRITE_STARTED +→ WRITTEN_TO_LOCAL_TRANSPORT +→ CLIENT_RECEIVED_ACK? // 별도 Protocol이 있을 때만 +→ CLIENT_APPLIED_ACK? // 별도 Application ACK가 있을 때만 + +Connection Evidence +OPEN +→ HEARTBEAT_ALIVE +→ SUSPECTED_HALF_OPEN +→ DRAINING +→ CLOSE_SENT / CLOSE_RECEIVED +→ CLOSED | ABNORMAL +``` + +즉, + +```text +sendMessage() 성공 +≠ Client 수신 + +Client 수신 +≠ Client Application 적용 + +STOMP RECEIPT +≠ Application Transaction Commit + +TCP/WebSocket 연결 유지 +≠ Client Application 정상 + +Application Commit +≠ Response가 Client에게 관측됨 +``` + +이어야 합니다. STOMP 1.2의 `RECEIPT`은 해당 Client Frame을 서버가 처리했다는 증거이며 이전 Frame들이 서버에 수신됐다는 누적 증거로 쓸 수 있지만, 규격은 이전 Frame들이 완전히 처리되었다는 뜻은 아니라고 명시합니다. 따라서 `RECEIPT`을 업무 트랜잭션 커밋 증거로 바꾸어 해석하면 안 됩니다. citeturn11view1turn21search0 + +이 판단이 전체 플랫폼 설계의 중심축이어야 합니다. + +## 책임 경계와 공개 계층·모듈 구조 + +### 인접 플랫폼과의 경계 + +제시하신 경계는 전반적으로 타당합니다. 특히 **Messaging과 WebSocket 사이의 경계가 가장 중요**합니다. Spring도 WebSocket을 HTTP와 다른 비동기 메시징 구조라고 설명하지만, 그것이 곧 durable messaging을 의미하지는 않습니다. STOMP 역시 Reliability와 Destination의 실제 의미를 서버별 구현에 맡깁니다. citeturn17search0turn21search0 + +| 인접 모듈 | WebSocket이 소유 | 인접 모듈이 소유 | +|---|---|---| +| `web` | Upgrade 성공 이후 Connection Runtime | HTTP Route, Forwarded Header 정규화, HTTP 인증 진입, Upgrade 이전 오류 | +| `security` | 인증 결과를 Connection Context에 유지, 메시지 권한 적용 | Token·Session 검증, Actor·Tenant·Permission 원천 | +| `messaging` | 현재 연결 Session으로 Live Push | **Durable Event, ACK, Retry, Replay, DLQ, Offset** | +| `redis` | Presence·Session Index·Ephemeral fan-out 사용 | TTL·원자 연산·Pub/Sub 자체 의미론 | +| `graphql` | WebSocket transport adapter | GraphQL operation, subscription, GraphQL error semantics | +| `grpc` | 브라우저·Client 중심 장기 양방향 연결 | 내부 서비스 typed RPC와 gRPC streaming | +| `fileserver` | 파일 상태·reference event | 대용량 byte 업·다운로드, Range, 검사 | +| `notification` | “새 알림 있음” live signal | Inbox·읽음 상태·채널 전달 상태 | +| `jpa`·`mongodb` | Application Use Case 호출 | Transaction, Query, Repository | +| `httpclient` | WebSocket 외 일반 outbound HTTP와 분리 | HTTP 호출·retry 정책 | + +특히 다음 연결은 금지하는 것이 좋습니다. + +```text +WebSocketHandler + → JpaRepository 직접 호출 + +@MessageMapping + → MongoTemplate 직접 상태 전이 + +WebSocket Session + → Kafka ACK 의미를 직접 흉내냄 + +Redis Pub/Sub + → durable replay라고 선언 + +WebSocket Binary Frame + → 대형 파일 업로드 + +STOMP /queue/** + → 이름만 보고 durable queue라고 선언 +``` + +STOMP 규격상 `/queue/foo`라는 문자열 자체에는 Queue durability 같은 의미가 없습니다. Destination은 서버 구현이 해석하는 opaque string이고, 전달 신뢰성도 Destination과 Broker별 설정에 달려 있습니다. citeturn21search0 + +### 공개 기능 계층 + +권장 공개 계층은 다음과 같습니다. + +| 공개층 | 공개 대상 | 포함 기능 | +|---|---|---| +| **WS1 Standard Typed WebSocket** | 일반 애플리케이션 | Endpoint, JSON Typed Message, Request–Response, Event, Context, Auth, Heartbeat, Bounded Queue | +| **WS2 Advanced Messaging** | 고급 실시간 서비스 | Subscription, Application ACK, Resume, Sequence, Binary Codec, STOMP | +| **WS3 Infrastructure Extension** | 플랫폼·인프라 | Broker Relay, Multi-node Fan-out, Compression, SockJS, H2/H3 Profile | +| **WS4 Admin Plane** | 운영자 | Session Drain, Disconnect, Protocol disable, Connection snapshot, maintenance broadcast | + +일반 도메인 개발자에게는 `WebSocketSession`, Reactor Netty native channel, 임의 `SimpMessagingTemplate`, raw Destination 생성, `ConcurrentWebSocketSessionDecorator` 구성 등을 직접 노출하기보다 등록된 Endpoint/Profile/Message Catalog를 제공하는 편이 좋습니다. Servlet WebSocket의 underlying standard session은 concurrent send를 직접 안전하게 제공하지 않기 때문에 Spring도 동기화 또는 `ConcurrentWebSocketSessionDecorator` 사용을 안내합니다. citeturn0search1turn12search0 + +### 권장 의존성 구조 + +제시한 모듈 분리는 그대로 채택할 가치가 높습니다. + +```text +modules/websocket/ +├── websocket-core-api +├── websocket-protocol +├── websocket-session +├── websocket-security +├── websocket-resilience +├── websocket-observability +│ +├── websocket-servlet +├── websocket-webflux +│ +├── websocket-stomp +├── websocket-broker-relay +├── websocket-cluster +├── websocket-resume +├── websocket-client +├── websocket-admin +│ +├── websocket-spring-boot-starter-mvc +├── websocket-spring-boot-starter-webflux +│ +├── websocket-testkit-core +├── websocket-testkit-servlet +├── websocket-testkit-webflux +├── websocket-testkit-browser +└── websocket-testkit-proxy +``` + +의존 방향은 다음처럼 단방향으로 고정하는 것이 좋습니다. + +```text +core-api + ↑ +protocol / session / security / resilience / observability + ↑ +servlet webflux + ↑ ↑ +starter-mvc starter-webflux + +stomp + └─ broker-relay + +resume + └─ messaging bridge abstraction + +cluster + └─ redis / messaging capability adapter + +admin + └─ session abstraction +``` + +`core-api`에는 Jakarta WebSocket, Servlet, Reactor, Netty, STOMP 타입을 넣지 않는 편이 좋습니다. 같은 논리로 MVC와 WebFlux Starter도 상호 배타적으로 두는 것이 안전합니다. Spring Boot는 servlet과 reactive 스택을 별도로 구성하며, `spring-boot-starter-web`과 `spring-boot-starter-webflux`가 함께 있으면 기본적으로 MVC를 선택하므로 우연한 Stack 선택을 피하는 정책이 필요합니다. citeturn18search3 + +기본 Starter에서는 다음을 제외하는 것이 적절합니다. + +```text +websocket-stomp +websocket-broker-relay +websocket-cluster +websocket-resume +websocket-client +SockJS +permessage-deflate +HTTP/2 WebSocket Profile +HTTP/3 WebSocket Profile +``` + +이는 “지원하지 않는다”가 아니라 **WS1의 기본 런타임을 가볍고 예측 가능하게 유지하면서 WS2·WS3 기능은 명시적으로 선택하게 한다**는 의미입니다. + +## Handshake·인증·보안·Typed Protocol 계약 + +### Handshake는 HTTP와 WebSocket의 경계선이다 + +Classic WebSocket은 HTTP request로 시작하여 성공 시 `101 Switching Protocols`로 전환됩니다. Spring Framework도 Upgrade 전과 후가 전혀 다른 프로그래밍 모델임을 강조하며, Upgrade 이후에는 한 연결을 통해 애플리케이션 메시지가 계속 흐릅니다. citeturn17search0turn7view2 + +권장 파이프라인은 다음과 같습니다. + +```text +HTTP Request +→ Trusted Proxy / Forwarded Header 정규화 +→ WebSocket Endpoint Profile 선택 +→ Host / Origin 검증 +→ HTTP Authentication 또는 Connection Ticket 검증 +→ Actor / Tenant 후보 Context 생성 +→ Subprotocol 협상 +→ Extension 협상 +→ Connection / Tenant / IP Admission +→ 101 Switching Protocols +→ Protocol-level Authentication 완료 +→ Session OPEN +``` + +Upgrade 이전에는 기존 `web` 플랫폼의 HTTP 오류 계약을 그대로 사용할 수 있습니다. + +| Handshake 상황 | 권장 HTTP 결과 | +|---|---| +| Handshake 구조 오류 | `400` | +| 필수 HTTP 인증 실패 | `401` | +| Origin·Endpoint 권한 거부 | `403` | +| 존재 은닉이 필요한 Endpoint | `404` | +| 중복 Connection 정책 충돌 | `409` | +| 연결 Rate Limit | `429` | +| Drain·과부하 Admission 거부 | `503` | + +반대로 `101` 이후에는 HTTP `ProblemDetail`을 보낼 수 없으므로 Typed `ERROR` message 또는 WebSocket Close code로 전환해야 합니다. RFC 6455에서도 `101` 이외의 Handshake response는 HTTP semantics를 유지하지만 성공적으로 protocol switch가 완료된 뒤에는 WebSocket framing으로 통신합니다. citeturn8view2turn17search0 + +### Origin 검증은 필수 보안 경계 + +Spring Security 공식 문서는 브라우저의 WebSocket 연결에는 일반적인 Same Origin Policy가 자동 적용되지 않으므로 서버가 이를 명시적으로 보호해야 한다고 강조합니다. Cookie 인증 상태에서 Origin을 무제한으로 허용하면 다른 사이트가 사용자의 인증 상태를 이용하는 Cross-Site WebSocket Hijacking 문제가 생길 수 있습니다. Spring Security는 STOMP 구성에서 `CONNECT`에 CSRF token을 요구하는 방식을 제공합니다. citeturn17search1 + +Stable 기본 정책은 다음이 적절합니다. + +```text +Origin +→ Exact Allowlist + +Wildcard Subdomain +→ 기본 금지, 등록 Profile만 허용 + +null Origin +→ 기본 거부 + +Cookie Authentication +→ Origin 검증 필수 + +STOMP + Cookie Session +→ CONNECT CSRF 사용 + +Cross-origin Token Profile +→ Endpoint별 명시 Opt-in +``` + +다음은 기본 금지로 두는 것이 좋습니다. + +```text +allowedOrigins = * +Cookie Authentication + Origin 미검증 +요청 Origin을 그대로 Allow +장기 Access Token을 query parameter에 사용 +모든 STOMP MESSAGE / SUBSCRIBE permitAll +Client가 보낸 actorId / tenantId 신뢰 +``` + +Spring Security는 STOMP에서 inbound `MESSAGE`와 `SUBSCRIBE`를 Destination별로 통제할 수 있으며, 특히 broker prefix로 직접 MESSAGE를 보내 시스템 발신자를 가장하거나 다른 사용자용 Destination을 SUBSCRIBE하는 형태를 막아야 한다고 설명합니다. citeturn17search1 + +### 브라우저 인증 Profile + +브라우저 표준 `WebSocket` 생성 인터페이스는 URL과 subprotocol을 중심으로 제공되며 애플리케이션이 일반 HTTP client처럼 임의의 `Authorization` 헤더를 자유롭게 추가하는 인터페이스는 제공하지 않습니다. 반면 Handshake는 브라우저 credential 정책에 따라 Cookie 등의 인증정보를 사용할 수 있습니다. citeturn7view0turn5search1 + +따라서 다음과 같이 분리하는 것이 좋습니다. + +| 인증 Profile | 지원 등급 | 권장 의미 | +|---|---|---| +| Cookie / HTTP Session | Stable | HTTP 인증 Principal 승계 + Exact Origin | +| One-time Connection Ticket | **Stable 권장** | Bearer를 URL에 장기 노출하지 않는 브라우저 연결 | +| STOMP `CONNECT` Bearer | Advanced | `ChannelInterceptor`에서 인증 | +| Query Long-lived Access Token | 비지원 | Proxy·Access Log·History 노출 위험 | +| Protocol 중간 Re-auth | Experimental | 복잡성이 크므로 초기 Stable 제외 | + +Spring Security는 HTTP Handshake에서 인증된 `Principal`을 WebSocket으로 넘겨주는 모델을 지원합니다. STOMP에서 별도 token 인증을 원할 경우 `CONNECT` frame의 header를 `ChannelInterceptor`에서 처리할 수 있습니다. citeturn17search1turn5search1 + +One-time Ticket은 표준 기능이 아니라 **플랫폼 자체 Profile**로 설계해야 합니다. + +```text +POST /websocket-tickets +→ ticket 발급 + +Ticket: +- random high-entropy identifier +- 매우 짧은 TTL +- one-time atomic consumption +- actor binding +- tenant binding +- endpoint binding +- origin binding +- clientInstanceId 선택 binding +``` + +그리고 Access Log에는 Ticket 전체 값을 남기지 않는 것이 적절합니다. + +장기 Connection의 Credential 만료 정책은 Stable에서 **“만료·권한회수 시 현재 Connection 종료 → 새 Credential로 재연결”**을 기본으로 잡는 편이 낫습니다. Connection 내부 reauthentication은 state machine·race condition·권한 회수 처리 비용이 커지므로 Advanced/Experimental로 남기는 편이 안전합니다. + +### Subprotocol은 Production Endpoint에서 명시적으로 협상 + +WebSocket 표준은 `Sec-WebSocket-Protocol`로 상위 protocol을 협상할 수 있습니다. Spring도 STOMP 등의 고수준 protocol을 이 header를 통해 선택하는 것을 지원합니다. citeturn8view2turn17search0 + +권장 Raw protocol 이름은 다음처럼 **Major version + codec**을 포함하는 형태입니다. + +```text +hyeonworks.realtime.v1.json +hyeonworks.realtime.v1.protobuf +v12.stomp +graphql-transport-ws +``` + +Production Typed Endpoint는: + +```text +지원되는 Subprotocol 하나 선택 +→ 성공 + +지원되는 공통 Protocol 없음 +→ Handshake 거부 +``` + +로 처리하고, subprotocol 없이 “아무 JSON이나 받아들이는” 모드는 Local 또는 Compatibility profile로 제한하는 것을 권장합니다. + +### Typed Message Envelope + +Stable Raw JSON protocol에는 무조건 모든 필드를 넣는 것이 아니라 **공통 식별 필드 + 메시지 종류별 선택 필드**를 두는 편이 좋습니다. + +```json +{ + "type": "document.updated", + "version": 1, + "messageId": "01K...", + "correlationId": "01K...", + "streamId": "document:abc", + "sequence": 42, + "occurredAt": "2026-08-14T06:00:00Z", + "expiresAt": "2026-08-14T06:01:00Z", + "payload": {} +} +``` + +권장 의미는 다음과 같습니다. + +| 필드 | 계약 | +|---|---| +| `type` | 등록된 stable wire name | +| `version` | 해당 message schema major/version | +| `messageId` | 메시지 인스턴스 식별 | +| `correlationId` | Request–Response 연결 | +| `causationId` | 필요 시 원인 message | +| `streamId` | Ordering·Resume 대상 logical stream에서만 | +| `sequence` | 해당 stream 내부 monotonically increasing sequence | +| `occurredAt` | 서버 기준 이벤트 시각 | +| `expiresAt` | 오래된 Command 재실행 방지용 선택 필드 | +| `payload` | Message type별 DTO | + +모든 메시지에 `sequence`, `idempotencyKey`, `subscriptionId`를 강제하기보다 메시지 family별 schema를 만드는 것이 좋습니다. + +```text +RequestMessage +ResponseMessage +CommandMessage +EventMessage +SubscribeMessage +AckMessage +ErrorMessage +ResumeMessage +SnapshotMessage +``` + +다음은 금지하는 것이 적절합니다. + +```text +messageType = Java FQCN +payload = Map +Entity 자체 직렬화 +Java Serialization +무제한 polymorphic deserialization +한 Envelope 안에 모든 command payload union 수작업 +``` + +### Codec 정책 + +| Codec | 등급 | 정책 | +|---|---|---| +| UTF-8 JSON | Stable 기본 | Browser 친화적, Contract Test 필수 | +| Protobuf Binary | Advanced 권장 | 강한 schema가 필요한 고성능 client | +| CBOR | Advanced 선택 | 실제 요구·SDK 지원이 있을 때 | +| Raw Binary | 제한 | 사전 등록된 Message Profile만 | +| Java Serialization | 비지원 | Wire contract로 사용하지 않음 | + +대용량 byte는 Typed Event에서 object/file reference를 전달하고 fileserver/object-storage가 byte transport를 담당하도록 유지하는 것이 좋습니다. + +## 실행 증거·Idempotency·Ordering·ACK·Resume 계약 + +### 증거 모델은 플랫폼의 핵심 API여야 한다 + +질문의 핵심인 “어디까지 갔는가”를 정확히 답하려면 다음 단계가 필요합니다. + +| 단계 | 서버가 증명할 수 있는 것 | 재호출 판단 | +|---|---|---| +| `FRAME_RECEIVED` | WebSocket frame이 runtime에 도착 | 업무 실행 여부는 모름 | +| `MESSAGE_ASSEMBLED` | Fragment 조립 완료 | 아직 재실행 안전 | +| `MESSAGE_VALIDATED` | Protocol/schema 검증 완료 | 아직 업무 미실행 | +| `MESSAGE_AUTHORIZED` | Transport/message 권한 통과 | 아직 업무 미실행 | +| `APPLICATION_STARTED` | Use Case 진입 | **Commit 여부 불명확 가능** | +| `APPLICATION_COMMITTED` | 업무 결과의 durable evidence 존재 | 다시 실행하지 않고 결과 Replay | +| `APPLICATION_FAILED` | 정의된 실패로 종료 | 실패 유형에 따라 재시도 | +| `RESPONSE_QUEUED` | Outbound queue에 등록 | Client 수신 증거 아님 | +| `WRITE_STARTED` | local transport 쓰기 시작 | Client 수신 증거 아님 | +| `WRITTEN_LOCALLY` | framework/local transport 단계 완료 | Client 적용 증거 아님 | +| `CLIENT_ACKED` | protocol ACK를 Client가 보냄 | ACK 정의 범위만 증명 | +| `CLIENT_APPLIED` | app-level 적용 ACK가 존재 | Client application 반영 증거 | + +여기서 `APPLICATION_COMMITTED`는 WebSocket runtime 자체가 추측하면 안 됩니다. **Application transaction과 Idempotency/Result ledger가 durable evidence를 제공해야 합니다.** + +예: + +```text +Command(commandId, idempotencyKey) + ↓ +Application Use Case + ↓ +DB Transaction +├─ Domain State 변경 +└─ Command Result / Idempotency Record 저장 + ↓ COMMIT +APPLICATION_COMMITTED +``` + +그 뒤 소켓이 끊겨도 결과는 다음 연결에서 재조회할 수 있어야 합니다. + +### Commit 후 Response 유실 + +가장 위험한 케이스는 다음입니다. + +```text +Client + → COMMAND C42 + +Server + → APPLICATION_STARTED + → DB COMMIT + → Response 생성 + → Socket write 시작 + +Network + → 연결 단절 + +Client + → Response 미관측 +``` + +이때 Client의 올바른 상태는 `FAILED`가 아니라: + +```text +COMPLETION_UNKNOWN_TO_CLIENT +``` + +입니다. + +그리고 재연결 후: + +```text +COMMAND C42 재전송 + ↓ +Idempotency Ledger 조회 + ├─ COMPLETED + │ → 저장된 Result 반환 + │ + ├─ PROCESSING + │ → 진행 상태 반환 + │ + ├─ ABSENT + │ → 새 실행 + │ + └─ 동일 key + 다른 fingerprint + → conflict +``` + +으로 처리해야 합니다. + +따라서 상태 변경용 WebSocket Command에는 다음 중 하나가 필수입니다. + +```text +idempotencyKey +client-generated commandId +client-generated resourceId +reconciliation query +``` + +**Connection sequence만으로 mutation idempotency를 보장하면 안 됩니다.** Connection은 끊기고 다시 만들어질 수 있고, Resume window 역시 업무 idempotency TTL과 동일한 개념이 아니기 때문입니다. + +중요한 mutation이 이미 HTTP나 gRPC에서 명확하게 표현되고 있다면 다음 구조도 우선 검토할 가치가 있습니다. + +```text +HTTP/gRPC +→ Command 수행 + Idempotency + +WebSocket +→ 결과·상태 변경 Live Event 전달 +``` + +이렇게 하면 WebSocket이 mutation transport와 durable command ledger까지 모두 소유하는 복잡성을 크게 줄일 수 있습니다. + +### STOMP `RECEIPT`과 ACK의 정확한 의미 + +STOMP 1.2에서: + +- `RECEIPT`은 요청한 Frame에 대해 서버가 처리했다는 protocol-level 응답입니다. +- 이전 Frame들이 서버에 수신됐다는 누적 증거로는 사용할 수 있지만, 이전 모든 Frame의 최종 업무 처리를 보장하지 않습니다. +- `ack:auto`는 별도 Client ACK가 필요 없습니다. +- `ack:client`는 cumulative acknowledgment입니다. +- `ack:client-individual`은 개별 메시지 acknowledgment입니다. +- Connection이 ACK 전에 실패하면 서버가 메시지를 재전달할 수 있으나 구체적인 redelivery semantics는 서버 구현에 의존합니다. citeturn11view1turn11view2turn21search0 + +따라서 플랫폼 용어를 다음과 같이 분리해야 합니다. + +```text +TRANSPORT_WRITE +PROTOCOL_RECEIPT +BROKER_DELIVERY +BROKER_ACK +APPLICATION_COMMIT +CLIENT_RECEIVED +CLIENT_APPLIED +``` + +이 단어들을 서로 alias하지 않는 것이 중요합니다. + +### Ordering은 WebSocket Frame 순서와 Application 순서가 다르다 + +하나의 연결에서 네트워크 바이트의 순서가 유지되더라도, Spring STOMP의 `clientInboundChannel`과 `clientOutboundChannel`은 thread pool 기반으로 처리되므로 application handling과 publish 결과가 서로 다른 thread에서 수행되어 원래 순서와 달라질 수 있습니다. Spring은 `setPreserveReceiveOrder(true)`와 `setPreservePublishOrder(true)`를 제공하지만, 순서 보장에는 성능 비용이 있다고 명시합니다. citeturn20search1 + +따라서 다음 profile이 적절합니다. + +| Ordering Profile | 사용 예 | 계약 | +|---|---|---| +| `UNORDERED_LOW_LATENCY` | presence, typing | 순서 의존 금지 | +| `SESSION_ORDERED` | 한 connection command chain | session별 serialize | +| `SUBSCRIPTION_ORDERED` | subscription update | subscription별 sequence | +| `STREAM_KEY_ORDERED` | document/room/entity stream | logical stream별 sequence | + +특히 Multi-node와 Resume를 고려한다면 `sessionSequence`보다: + +```text +streamId + streamSequence +``` + +가 더 중요한 복구 기준입니다. + +예: + +```text +document:123 + seq=100 + seq=101 + seq=102 +``` + +Client는: + +```text +lastReceivedSequence +lastAppliedSequence +``` + +를 구분해야 합니다. + +`lastReceivedSequence=102`지만 UI/State Store에는 `101`까지만 반영하다 브라우저가 죽을 수도 있기 때문입니다. Resume 기준은 일반적으로 **`lastAppliedSequence`**가 더 안전합니다. + +### Subscription 계약 + +권장 Subscription request는 다음 정도면 충분합니다. + +```json +{ + "type": "subscribe", + "version": 1, + "messageId": "...", + "payload": { + "subscriptionId": "sub-01", + "topic": "document.changes", + "resourceId": "doc-123", + "resumeFrom": 101 + } +} +``` + +다음은 서버 등록 Catalog에 의해 통제합니다. + +```text +Topic Allowlist +Filter Allowlist +Projection Profile +Maximum subscriptions / connection +Maximum subscriptions / actor +Event rate +Outbound queue budget +Authorization profile +Resume capability +``` + +권한은 “Connection할 때 로그인했는가”와 “이 Event를 지금 받을 권리가 있는가”를 구분해야 합니다. Spring Security 역시 MESSAGE와 SUBSCRIBE의 Destination 권한을 구별하며, outbound 자체를 모두 검사하는 대신 subscription을 엄격히 보호하는 방식을 설명합니다. citeturn17search1 + +민감한 스트림에서 권한 회수가 즉시 반영되어야 한다면 다음 중 하나가 필요합니다. + +```text +Event delivery 시 Authorization 재검증 +또는 +Permission Revocation Event → subscription revoke / session close +또는 +짧은 Connection Max Age +``` + +모든 Event마다 DB Authorization Query를 수행하는 것은 비용이 크므로 Endpoint별 정책으로 두는 것이 좋습니다. + +### Reconnect와 Resume + +RFC 6455에는 끊어진 Application Stream의 replay cursor나 resume semantics가 정의돼 있지 않습니다. 끊어진 뒤에는 새 WebSocket Connection을 만들고 애플리케이션 protocol이 복구를 정의해야 합니다. citeturn7view2turn17search0 + +Stable Resume protocol은 다음 형태가 적절합니다. + +```text +Client disconnect + ↓ +Exponential Backoff + Jitter + ↓ +새 Handshake + 새 Authentication + ↓ +RESUME +{ + streamId, + resumeToken, + lastAppliedSequence, + snapshotVersion +} + ↓ +Server +├─ history available +│ → replay sequence+1 ... +│ +├─ history compacted / gap too old +│ → SNAPSHOT_REQUIRED +│ +├─ permission changed +│ → RESUME_DENIED +│ +└─ token expired + → RESUME_EXPIRED +``` + +Resume 성공 뒤에도 Client는 duplicate detection을 수행해야 합니다. + +```text +seq <= lastAppliedSequence +→ duplicate, ignore + +seq == lastAppliedSequence + 1 +→ apply + +seq > lastAppliedSequence + 1 +→ GAP, stop incremental apply +→ resume/snapshot request +``` + +Replay source는 WebSocket Node의 메모리가 아니라 **Messaging/Event Log 등 durable capability**여야 합니다. WebSocket Resume module은 cursor와 snapshot orchestration만 담당하는 것이 모듈 경계에 맞습니다. + +### Presence는 사실이 아니라 관측 결과다 + +WebSocket `OPEN`만 보고: + +```text +user.online = true +``` + +라고 업무 사실을 선언하면 안 됩니다. 네트워크 partition, background browser, delayed heartbeat, proxy timeout 때문에 실제 사용 상태와 Socket 관측 상태가 일치하지 않을 수 있기 때문입니다. + +권장 모델은: + +```text +lastObservedAt +activeConnectionCount +lastHeartbeatAt +presenceState = ONLINE | IDLE | STALE | OFFLINE +``` + +처럼 **관측 시각이 포함된 상태**입니다. + +Redis에는 Session 객체 자체가 아니라: + +```text +actor fingerprint +connection count +node id +lastObservedAt +TTL +``` + +정도의 summary만 저장하는 것이 적절합니다. + +## 런타임·Backpressure·Heartbeat·멀티인스턴스·STOMP 운영 + +### Servlet과 WebFlux 실행 모델 + +Servlet WebSocket에서는 동일 Session에 여러 thread가 동시에 write하는 구조를 피해야 합니다. Spring의 `ConcurrentWebSocketSessionDecorator`는 하나의 thread가 실제 send를 수행하도록 하고 send-time limit 및 buffer-size limit을 적용할 수 있습니다. Buffer overflow 처리 전략도 제공됩니다. citeturn12search0turn0search1 + +따라서 Servlet runtime은 다음 구조로 고정하는 것을 권장합니다. + +```text +Application Event + ↓ +SessionOutboundQueue + ↓ +Serialized Writer + ↓ +ConcurrentWebSocketSessionDecorator + ↓ +Container Session +``` + +```text +Application thread +→ WebSocketSession.sendMessage 직접 호출 +``` + +을 일반 API로 노출하지 않는 것이 좋습니다. + +WebFlux에서는 `WebSocketSession.receive()`가 inbound `Flux`를, `send(Publisher)`가 outbound 완료를 나타내는 reactive API를 제공합니다. Reactor Netty 등의 pooled buffer를 async boundary 뒤까지 보관할 경우 DataBuffer retain/release 수명도 고려해야 합니다. citeturn15view0turn4view3turn4view4 + +다만 **Reactive Streams를 쓴다는 이유만으로 브라우저까지 end-to-end backpressure가 자동 제공된다고 선언하면 안 됩니다.** 브라우저 WebSocket API에는 reactive demand protocol이 없으므로 서버의 bounded buffering과 message-level 정책은 여전히 필요합니다. 브라우저가 송신할 때는 `bufferedAmount`로 아직 network에 전달되지 않은 application data byte 수를 관찰할 수 있습니다. citeturn7view0 + +### Slow Consumer 정책 + +모든 message type에 같은 overflow policy를 적용하면 안 됩니다. + +| Message 성격 | Queue 초과 시 | +|---|---| +| 업무 Command 결과 | **Drop 금지**, disconnect + reconciliation/resume | +| 업무 상태 전이 Event | **Drop 금지**, disconnect + durable resume | +| Presence / Typing | `DROP` 또는 `COALESCE` 허용 | +| 최신 가격·상태 Snapshot | `COALESCE_BY_KEY` 가능 | +| Durable Event | local buffer 무한 확대 금지, connection close 후 cursor replay | +| Admin/security notice | 우선순위 Queue 또는 즉시 close | + +권장 overflow policy catalog: + +```text +DISCONNECT +DROP_LATEST +DROP_OLDEST +COALESCE_BY_KEY +SNAPSHOT_REQUIRED +``` + +`DROP_*`는 메시지 schema가 explicitly lossy라고 선언한 경우에만 허용해야 합니다. + +Spring STOMP도 client outbound가 느릴 때 한 thread가 실제 send를 하고 추가 메시지가 buffer에 쌓이는 구조이며 `sendTimeLimit`과 `sendBufferSizeLimit`을 제공하므로, 무제한 buffering을 피해야 합니다. Spring 문서는 `clientInboundChannel`과 `clientOutboundChannel`의 thread pool 및 queue 설정 또한 성능에 직접 영향을 준다고 설명합니다. citeturn20search0 + +### 초기 Resource Budget + +아래 값은 프로토콜 표준값이 아니라 **성능 시험을 시작하기 위한 Backend Skeleton 초기 profile 권고값**입니다. 서비스별 부하 시험 후 올리는 방식이 안전합니다. + +| 항목 | Stable 시작값 후보 | 비고 | +|---|---:|---| +| Raw JSON assembled message | 64 KiB | 더 큰 payload는 별도 Profile | +| Binary message | 256 KiB | Advanced | +| STOMP inbound message | 64 KiB | Spring STOMP client 기본 inbound limit도 64 KiB citeturn19search0 | +| JSON nesting depth | 32 | Codec guard | +| Array elements | 1,000 | Message schema가 더 낮게 설정 가능 | +| String bytes | 32 KiB | field별 더 낮은 제한 권장 | +| Subscriptions / connection | 32 | Profile별 조정 | +| In-flight requests | 32 | 무제한 correlation 금지 | +| Outbound queue | 512 KiB + message count limit | byte와 count 모두 제한 | +| Send stall limit | 10 s 시작값 | 실제 proxy/network 시험 필요 | +| Global buffered bytes | 반드시 상한 | Heap 보호 | +| Connection / actor | endpoint별 명시 | browser multi-tab 고려 | +| Reconnect rate | actor·IP·tenant별 제한 | reconnect storm 방지 | + +여기서 **Frame size, assembled WebSocket Message size, STOMP Message size, decoded JSON size, outbound queue size는 각각 별도 제한**이어야 합니다. Spring STOMP 자체도 WebSocket message를 조립해 더 큰 STOMP message를 구성할 수 있으며 이를 위해 별도의 message size limit을 제공합니다. citeturn20search0turn19search0 + +### Heartbeat 계층을 분리해야 한다 + +다음은 서로 같은 기능이 아닙니다. + +```text +TCP Keepalive +WebSocket Ping/Pong +STOMP Heartbeat +Application Heartbeat +Nginx proxy_read_timeout +Presence TTL +``` + +RFC 6455에서 Ping을 받은 endpoint는 closing 상태가 아니라면 Pong으로 응답해야 하며 Ping/Pong은 keepalive와 peer responsiveness 확인에 사용할 수 있습니다. citeturn8view3 + +STOMP heartbeat는 `CONNECT`와 `CONNECTED`의 `heart-beat` 값으로 양측 송신 능력과 수신 희망 간격을 교환하고 각 방향의 실제 최소 간격을 계산합니다. citeturn21search0 + +Nginx 공식 WebSocket proxy 문서는 upstream server가 아무 데이터도 보내지 않으면 기본적으로 60초 후 연결을 종료한다고 설명하며, `proxy_read_timeout`을 늘리거나 WebSocket Ping을 주기적으로 보내 연결 활동과 생존 확인을 수행할 수 있다고 명시합니다. citeturn17search2turn16view1 + +따라서 설정 관계를 다음처럼 계약화해야 합니다. + +```text +heartbeatInterval + < heartbeatTimeout + < proxyReadTimeout +``` + +예를 들어 실제 Profile을 `25s heartbeat / 55s failure / 75s proxy read`처럼 잡을 수 있지만, 구체 숫자는 Nginx·Ingress·LB·모바일 네트워크 시험 결과로 결정하는 편이 좋습니다. + +Application heartbeat는 transport heartbeat와 별도로 정말 필요한 경우만 사용합니다. 예를 들어 “Client app event loop가 정상적으로 state를 적용하고 있는지”가 중요하면 app-level `PING/PONG` 또는 state watermark를 별도 정의할 수 있습니다. + +### Browser outbound budget + +브라우저의 `WebSocket.bufferedAmount`는 `send()`한 데이터 중 아직 network로 전달되지 않은 byte 수를 표시하므로 Client SDK의 로컬 backpressure 신호로 유용합니다. 다만 이것은 상대 서버가 메시지를 받았다는 ACK가 아닙니다. citeturn7view0 + +Client SDK는 다음을 가져야 합니다. + +```text +maxBufferedAmount +bounded command queue +message priority +expiresAt +retryability +idempotency requirement +offline queue policy +``` + +특히 offline 상태에서 mutation을 무제한 저장한 뒤 재접속 시 전부 보내면 오래된 Command가 뒤늦게 실행될 수 있으므로: + +```text +expiresAt ++ +idempotencyKey ++ +explicit offline-capable flag +``` + +가 필요합니다. + +### Compression + +RFC 7692 `permessage-deflate`는 Opening Handshake에서 협상하는 per-message compression extension입니다. `server_no_context_takeover`, `client_no_context_takeover`, `server_max_window_bits`, `client_max_window_bits` 등의 파라미터로 양 방향 압축 context와 memory footprint를 제어할 수 있습니다. citeturn22search0 + +따라서 Stable 기본은: + +```text +permessage-deflate = OFF +``` + +가 적절하고 다음을 측정한 Endpoint만 Opt-in하는 것이 좋습니다. + +```text +bandwidth 절감률 +CPU / connection +memory / connection +p95/p99 send latency +decompressed size +compression context memory +sensitive data + attacker-controlled input 위험 +``` + +Binary Protobuf처럼 이미 compact한 payload는 compression 효율이 낮을 수 있으므로 Codec별 benchmark가 필요합니다. + +### Multi-instance Session 구조 + +실제 socket은 연결을 받은 application instance가 소유하므로 구조를 다음처럼 나누는 것이 좋습니다. + +```text +Node A +├─ actual WebSocketSession +├─ local subscription handlers +├─ local outbound queue +└─ local writer + +External Registry +├─ connectionId → nodeId +├─ actor → active node summaries +├─ subscription summary +└─ TTL / lastObservedAt + +Fan-out Capability +├─ ephemeral: Redis capability +└─ durable: Messaging capability +``` + +**Native Session 객체를 Redis에 직렬화하여 다른 Node로 이동시키는 구조는 금지**해야 합니다. + +Sticky session도 다음 문제를 해결하지 못합니다. + +```text +Pod restart +Node crash +Deployment +Reconnect +Resume history +Lost event +``` + +따라서 sticky routing은 최적화일 수 있어도 recovery contract가 되어서는 안 됩니다. + +### STOMP 지원 범위 + +Spring의 Simple Broker는 시작하기 쉬우나 STOMP 명령의 subset만 지원하고 ACK·RECEIPT 등을 지원하지 않으며 clustering에 적합하지 않습니다. Spring은 production-scale broadcast를 위해 external broker relay를 별도 옵션으로 제공합니다. citeturn19search1turn20search0 + +| 기능 | Simple Broker | Broker Relay | +|---|---|---| +| Local Pub/Sub | 지원 | 지원 | +| `SEND`·`SUBSCRIBE` | 기본 지원 | Broker 지원 범위 | +| ACK | 제한/비지원 | Broker capability | +| RECEIPT | 제한/비지원 | Broker capability | +| Cluster | 부적합 | 가능 | +| Durable queue | 보장하지 않음 | Broker·Destination 설정에 따라 | +| Redelivery | 보장하지 않음 | Broker 설정에 따라 | +| DLQ | 없음 | Broker capability | +| Transaction | 제한 | Broker capability | +| User Destination | Spring 변환 가능 | Broker와 결합 검증 | +| 운영 권장 | Local/Test·단일 node | Advanced production | + +Spring Broker Relay는 애플리케이션과 외부 Broker 사이에서 TCP 연결을 사용해 메시지를 양 방향 relay합니다. 따라서 Broker Relay를 채택할 때는 WebSocket socket 수뿐 아니라 Broker connection footprint, broker failover, heartbeat, reconnection, broker-side destination lifecycle을 별도로 부하 시험해야 합니다. citeturn19search1 + +Multi-server User Destination은 Spring의 user-destination/registry broadcast 기능을 이용해 다른 application server에 연결된 사용자를 찾는 구성이 가능하지만, 이 역시 Broker의 temporary queue 정리 및 destination semantics와 함께 검증해야 합니다. citeturn3search7turn3search3 + +## 오류·Close·Proxy·Shutdown·관측성 계약 + +### Error Message와 Close는 분리 + +Message 단위 오류가 발생했다고 항상 Connection을 끊는 것은 좋지 않습니다. + +```text +Recoverable message error +→ ERROR message +→ Connection 유지 + +Connection-scoped fatal error +→ Close +``` + +가 기본 원칙이어야 합니다. + +RFC/IANA WebSocket Close code 기준에서 주요 코드는 다음과 같습니다. citeturn9view2turn9view3turn8view0turn9view4turn21search1 + +| Close Code | 의미 | 플랫폼 사용 | +|---:|---|---| +| `1000` | Normal Closure | 정상 종료 | +| `1002` | Protocol Error | frame/subprotocol 위반 | +| `1003` | Unsupported Data | 지원하지 않는 data type | +| `1007` | Invalid Payload Data | invalid UTF-8 등 | +| `1008` | Policy Violation | 일반 protocol/security policy | +| `1009` | Message Too Big | size limit | +| `1011` | Internal Error | 예기치 못한 server failure | +| `1012` | Service Restart | rolling restart/drain | +| `1013` | Try Again Later | 일시 과부하 | +| `4000–4999` | Private Use | application close catalog | + +IANA registry는 `1012`를 Service Restart, `1013`을 Try Again Later로 등록하고 `4000–4999`를 Private Use 범위로 둡니다. citeturn21search1turn21search3 + +권장 private catalog는 다음과 같습니다. + +```text +4400 INVALID_MESSAGE +4401 AUTHENTICATION_REQUIRED +4403 ACCESS_DENIED +4408 HEARTBEAT_TIMEOUT +4409 DUPLICATE_CONNECTION +4422 VALIDATION_FAILED +4429 RATE_LIMITED +4503 OVERLOADED +``` + +다만 `VALIDATION_FAILED` 같은 Message 단위 오류는 일반적으로 Close보다 Typed `ERROR`가 우선입니다. Private Close는 “이 Connection을 더 이상 유지할 수 없는 이유”에 사용해야 합니다. + +Browser WebSocket API에서 script가 직접 `close()`에 지정할 수 있는 code는 `1000` 또는 `3000–4999` 범위이며, reason은 UTF-8 기준 123 bytes 이하여야 합니다. 따라서 Client-visible close reason에는 stack trace·SQL·token·PII를 넣으면 안 됩니다. citeturn7view0 + +### Nginx와 TLS + +Nginx reverse proxy에서 `Upgrade`와 `Connection`은 hop-by-hop header이므로 upstream으로 자동 전달되지 않으며 WebSocket proxying을 위해 명시적으로 처리해야 합니다. Nginx 공식 구성도 `Upgrade`와 `Connection`을 별도로 설정합니다. citeturn17search2 + +기본 계약은 다음처럼 두는 것이 좋습니다. + +```nginx +location /ws/ { + proxy_pass http://backend; + + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + + # deployment profile에 맞춰 명시 + proxy_read_timeout ...; + proxy_send_timeout ...; +} +``` + +Nginx 최신 문서 기준으로 `proxy_read_timeout` 기본값은 60초이며 “전체 응답 시간”이 아니라 두 successive read 사이 timeout입니다. WebSocket처럼 idle할 수 있는 장기 연결에서는 반드시 heartbeat profile과 정렬해야 합니다. citeturn16view0turn16view1 + +Forwarded header와 외부 URL 신뢰 모델은 `web` 모듈에서 이미 정한 정책을 재사용해야 합니다. + +```text +Client +→ Host Nginx + → untrusted Forwarded 제거 + → trusted X-Forwarded-* 재작성 +→ Backend +→ web platform normalization +→ WebSocket handshake context +``` + +WebSocket이 독자적인 `X-Forwarded-For` parsing 로직을 만들면 `web` 플랫폼과 client IP·scheme·host 결과가 달라질 수 있습니다. + +TLS 정책은 다음이 적절합니다. + +```text +Local +→ ws 허용 + +Dev / Staging / Prod +→ wss 필수 + +TLS termination +→ Nginx 또는 trusted ingress + +Backend plaintext +→ trusted internal network profile에서만 + +Origin / External Host +→ normalized web context 사용 +``` + +### Graceful Shutdown + +Spring Boot는 servlet/reactive server의 graceful shutdown을 제공하지만, **“Client가 어떤 sequence부터 다시 받아야 하는지”나 “어떤 WebSocket Command를 이제 받지 말아야 하는지”는 application protocol의 책임**입니다. 따라서 WebSocket 플랫폼은 별도의 drain state machine을 가져야 합니다. citeturn0search7 + +권장 흐름: + +```text +Readiness OFF +→ 신규 Handshake Admission 차단 +→ 기존 Connection 상태 DRAINING +→ SERVER_DRAINING Control Message + { + reconnectAfter, + resumeSupported, + deadline + } +→ 신규 Subscription 거부 +→ 신규 mutation Command 거부 +→ 이미 commit 중인 Command 제한 시간 처리 +→ Outbound Queue 제한 시간 drain +→ Close 1012 Service Restart +→ shutdown deadline 초과 시 강제 close +``` + +Connection이 무기한 지속되는 것을 허용하기보다: + +```text +maxConnectionAge +credentialExpiresAt +serverDrainDeadline +resumeWindow +``` + +를 두는 편이 Rolling Update 운영에 유리합니다. + +### Outbound WebSocket Client + +Backend가 외부 WebSocket provider와 연결하는 기능은 일반 `httpclient` retry profile을 그대로 사용하지 않는 것이 맞습니다. WebSocket client는 “RPC 재시도”가 아니라 **Connection 재수립 + Protocol 재협상 + Subscription 재등록 + Resume** 문제이기 때문입니다. + +Spring WebFlux WebSocket client는 Reactor Netty, Tomcat, Jetty, 표준 Java WebSocket client 구현을 지원합니다. citeturn15view0 + +권장 Named profile: + +```yaml +websocket: + clients: + market-feed: + uri: wss://provider.example/stream + protocol: provider.feed.v2 + tls: provider-ca + connect-timeout: 5s + heartbeat: 20s + idle-timeout: 60s + max-message-size: 64KiB + max-buffered-bytes: 512KiB + reconnect: + min-backoff: 500ms + max-backoff: 30s + jitter: true + resume: + supported: true +``` + +Profile에는 최소: + +```text +URI +TLS +Proxy +Subprotocol +Authentication +Handshake timeout +Heartbeat +Idle timeout +Max age +Message limits +Reconnect strategy +Resume strategy +Observability +``` + +를 포함해야 합니다. + +### 관측성 모델 + +장기 연결 하나에 거대한 tracing span 하나를 유지하기보다 **Handshake span + message operation span + connection metric** 구조가 운영상 더 적합합니다. + +권장 Metric: + +| 영역 | Metric | +|---|---| +| Connection | active, opened, rejected, closed, duration, abnormal close | +| Heartbeat | ping, pong, timeout, suspected half-open | +| Reconnect | attempt, success, resume success/failure | +| Message | inbound/outbound count·bytes | +| Validation | schema failure, unknown type/version | +| Security | authentication/authorization reject | +| Application | started, committed, failed | +| Ordering | duplicate, sequence gap | +| Backpressure | queue bytes/messages, slow consumer, drop, coalesce | +| Subscription | active, rejected, event lag | +| STOMP | broker availability, receipt timeout, relay disconnect | + +허용 tag: + +```text +endpointProfile +protocol +protocolVersion +messageTypeCatalog +operationCatalog +closeCode +outcome +node +resumeOutcome +``` + +금지 tag: + +```text +sessionId +connectionId +userId +tenantId raw +resourceId +messageId +subscriptionId +destination의 동적 부분 +token +payload +``` + +Connection ID 같은 값은 로그 필드나 trace correlation에 제한적으로 사용할 수 있어도 Metric tag로 사용하면 cardinality가 폭증하므로 금지하는 것이 좋습니다. + +Access log/event log 예: + +```text +timestamp +connectionFingerprint +actorFingerprint +endpointProfile +protocol +node +event = OPEN | CLOSE | RESUME | DRAIN +closeCode +duration +bytesIn +bytesOut +``` + +Admin plane은: + +```text +Connection Summary +Protocol Version Usage +Node별 active count +Slow Consumer count +Close Code distribution +Resume failure +Broker status +``` + +를 제공하되 payload/token/raw filters는 노출하지 않는 것이 좋습니다. + +다음 관리 작업은 모두 Audit 대상입니다. + +```text +Session Disconnect +Actor Session Disconnect +Tenant Drain +Endpoint Drain +Protocol Version Disable +Maintenance Broadcast +Resume State 수동 무효화 +``` + +## 지원 등급·테스트 전략·구현 로드맵 + +### 최종 기능 지원 매트릭스 + +| Capability | 최종 권고 등급 | 완료 조건 | +|---|---|---| +| Raw JSON Typed WebSocket | **Stable** | Servlet·WebFlux contract + browser/proxy test | +| Request–Response | **Stable** | correlation, timeout, cancellation, late response 정의 | +| Mutation Command | Stable 조건부 | Idempotency ledger 필수 | +| Typed Event | **Stable** | bounded queue + schema catalog | +| Server heartbeat | **Stable** | proxy timeout E2E 검증 | +| Exact Origin policy | **Stable 필수** | browser CSWSH test | +| Cookie/session auth | **Stable** | Origin + security integration | +| One-time ticket auth | **Stable 권장** | atomic single-use + TTL | +| Connection max age | **Stable** | reconnect/drain test | +| Session serialized writer | **Stable 필수** | concurrency stress test | +| Bounded outbound queue | **Stable 필수** | slow-consumer test | +| Sequence / Gap detection | **Stable for ordered streams** | duplicate/gap contract | +| Resume | **Advanced** | durable replay source 필요 | +| Snapshot fallback | **Advanced** | history-lost test | +| Subscription | **Advanced** | auth·limits·ordering 정의 | +| Application ACK | Advanced | 의미 명시 + ledger 필요 여부 결정 | +| STOMP 1.2 | **Advanced Stable** | protocol matrix | +| Simple Broker | Local/Test | cluster 사용 금지 | +| Broker Relay | Advanced | real broker fault test | +| Multi-node fan-out | Advanced | cross-node integration test | +| Presence | Advanced | TTL·stale semantics | +| Protobuf Binary | Advanced | generated client compatibility | +| CBOR | Advanced | 실제 client 수요가 있을 때 | +| `permessage-deflate` | Advanced Opt-in | CPU/memory/security benchmark | +| Outbound WS client | Advanced | reconnect/resume profile | +| SockJS | Legacy | 명시적 legacy requirement | +| HTTP/2 WS | Compatibility | end-to-end matrix | +| HTTP/3 WS | Experimental | end-to-end support 검증 | +| GraphQL WS semantics | **WebSocket에서 비소유** | GraphQL adapter만 | +| Durable Replay/DLQ | **비지원** | Messaging 사용 | +| Large file | **비지원** | Fileserver 사용 | +| Java serialization | **비지원** | — | +| WebSocket exactly-once | **비지원 선언** | idempotent business operation으로 대체 | + +### 핵심 Contract Test + +Mock WebSocketSession만으로 Stable을 선언해서는 안 됩니다. Spring의 실제 Servlet/WebFlux runtime, Nginx, TLS, browser를 모두 거쳐야 low-level connection semantics를 검증할 수 있습니다. Spring 자체도 Servlet WebSocket과 Reactive WebSocket의 실행 API가 다르고, STOMP에서는 별도 thread pools·buffers·broker relay가 개입합니다. citeturn17search0turn20search0turn15view0 + +**Handshake·Security** + +```text +101 정상 연결 +malformed handshake +unsupported subprotocol +missing subprotocol +Origin allowed / rejected +null Origin +Cookie session +expired session +one-time ticket success +ticket replay +ticket expiration +STOMP CONNECT token +CSRF CONNECT +Forwarded header spoof +Host spoof +connection rate limit +draining endpoint +``` + +Spring Security가 WebSocket에서 Same-Origin 방어와 STOMP CONNECT CSRF를 별도로 강조하므로 이 테스트는 Release Gate에 포함해야 합니다. citeturn17search1 + +**Message·Schema** + +```text +normal JSON +binary +fragmentation +invalid UTF-8 +malformed JSON +unknown message type +unknown schema version +unknown enum +oversized string +oversized array +deep JSON +assembled size overflow +compression +decompressed oversize +``` + +**Execution Evidence·Idempotency** + +```text +FRAME_RECEIVED 전 disconnect +MESSAGE_VALIDATED 후 reject +APPLICATION_STARTED 후 failure +APPLICATION_COMMITTED 직후 socket reset +response queue 전 disconnect +write 시작 후 disconnect +동일 commandId 재전송 +동일 idempotencyKey + 동일 fingerprint +동일 idempotencyKey + 다른 fingerprint +ledger PROCESSING 상태 crash +commit 후 reconnect + reconciliation +``` + +이 테스트가 플랫폼의 핵심 질문에 가장 직접적으로 답합니다. + +**Ordering** + +```text +동일 Session 병렬 inbound +동일 Subscription 병렬 event +preserveReceiveOrder off/on +preservePublishOrder off/on +cross-node event +duplicate sequence +missing sequence +out-of-order sequence +reconnect 경계 sequence +``` + +Spring STOMP의 ordering option이 기본 thread-pool reorder를 보완하는 기능이므로 해당 설정의 비용과 효과를 실제 throughput test에서 비교해야 합니다. citeturn20search1 + +**Backpressure** + +```text +slow browser +blocked network +queue bytes cap +queue message cap +send time cap +DROP_LATEST +DROP_OLDEST +COALESCE_BY_KEY +critical message overflow +global buffer exhaustion +browser bufferedAmount 증가 +WebFlux slow subscriber +``` + +Spring STOMP의 outbound send도 slow client에서 buffer가 증가할 수 있어 send-time과 buffer-size limit이 별도로 제공됩니다. citeturn20search0 + +**Heartbeat·Network** + +```text +Ping/Pong 정상 +Pong 손실 +Server Ping 정지 +half-open +Nginx proxy_read_timeout +TCP reset +mobile network switch +browser sleep +background tab +temporary packet loss +TLS termination restart +``` + +Nginx 기본 60초 idle timeout과 WebSocket Ping 사용 가능성을 실제 배포 설정에 맞춰 검증해야 합니다. citeturn17search2 + +**Resume** + +```text +normal resume +new node resume +lastAppliedSequence 정상 +duplicate event +sequence gap +history compacted +snapshot fallback +resume token expired +resume token replay +permission revoked +schema version changed +stream deleted +``` + +**Multi-instance** + +```text +Node A client connection +Node B business event +A로 cross-node fan-out +Node A kill -9 +Node C reconnect +registry TTL cleanup +stale registry entry +duplicate session registration +network partition between app and fan-out +``` + +**STOMP** + +```text +CONNECT / CONNECTED +heartbeat negotiation +SEND +SUBSCRIBE / UNSUBSCRIBE +ACK auto +ACK client +ACK client-individual +NACK +RECEIPT +ERROR +broker disconnect +broker reconnect +simple broker limitation +external relay +user destination multi-node +ordered publication +``` + +STOMP ACK mode와 redelivery 의미는 규격 및 Broker별 capability를 함께 검증해야 합니다. citeturn21search0turn19search1 + +### 장애·보안·성능 Gate + +성능 시험에서는 단순 messages/sec 하나만 보지 말고 다음을 함께 측정해야 합니다. + +```text +Concurrent connections / node +Handshake RPS +Reconnect RPS +Idle connection heap +Idle connection direct memory +Thread count +Event-loop utilization +Inbound messages/sec +Outbound messages/sec +p50 / p95 / p99 message latency +queue bytes / connection +global buffered bytes +slow consumer ratio별 처리량 +serialization CPU +compression CPU +broker relay latency +resume replay throughput +snapshot latency +GC pause +connection drain duration +``` + +특히 다음 부하 시나리오가 중요합니다. + +```text +정상 Client 100% +slow Client 1% +slow Client 10% +slow Client 50% + +동시에: +Node restart +Broker latency +Redis latency +Reconnect storm +``` + +Slow client 몇 개 때문에 전체 outbound thread pool이나 direct memory가 고갈되지 않는지 검증해야 합니다. Spring도 outbound 성능이 client network speed에 크게 영향을 받고 별도의 send/buffer limit이 필요하다고 설명합니다. citeturn20search0 + +Browser matrix는 최소: + +```text +Chromium +Firefox +WebKit + +Foreground +Background tab +Sleep / wake +Offline / online +Wi-Fi ↔ Mobile network +Browser close +Page navigation +``` + +까지 포함하는 것이 좋습니다. + +Runtime matrix: + +```text +Tomcat + Nginx + TLS +Jetty + Nginx + TLS +Reactor Netty + Nginx + TLS +``` + +를 Stable gate로 잡고, HTTP/2·HTTP/3는 별도 compatibility lane에서 검증합니다. Boot 4.1의 공식 servlet container 기준은 Tomcat 11.0.x와 Jetty 12.1.x이며 reactive server는 Reactor Netty·Tomcat·Jetty를 지원합니다. citeturn18search1turn18search3 + +### 단계별 구현 순서와 완료 조건 + +**기준선·경계 확정** + +먼저 `websocket-core-api`, MVC/WebFlux starter의 상호 배타성, `web`·`security`·`messaging`·`redis`와의 의존 방향을 확정합니다. + +완료 조건: + +```text +Boot 4.1 BOM +Java 21 +Tomcat / Reactor Netty 기본 profile +인접 모듈 dependency rule +금지 API architecture test +``` + +**Raw Typed Stable Runtime** + +다음으로 STOMP 없이 Raw JSON부터 완성하는 것이 좋습니다. + +```text +Handshake +Origin +Subprotocol +Connection Context +JSON Envelope +Message Catalog +Request–Response +Error +Serialized Writer +Size Limit +Heartbeat +Observability +``` + +완료 조건은 실제 Chromium + Nginx + Tomcat/Reactor Netty에서 기본 시나리오가 통과하는 것입니다. + +**실행 증거와 상태 변경 안전성** + +플랫폼의 가장 중요한 단계입니다. + +```text +Application Started evidence +Commit evidence abstraction +Idempotency capability bridge +Result ledger +Completion Unknown +Reconciliation +``` + +완료 조건: + +```text +DB Commit 직후 Network reset +→ Client retry +→ business mutation은 1회 +→ 이전 Result 회수 가능 +``` + +이 시나리오가 자동화 테스트로 증명되어야 합니다. + +**Backpressure·Ordering·Resource Budget** + +```text +Bounded queue +Serialized writer +Slow consumer classification +Sequence +Gap detection +Ordering profile +Global buffer admission +Browser bufferedAmount policy +``` + +완료 조건은 slow-client stress 중에도 fast-client p99와 server memory가 설정된 범위에서 유지되고, critical event drop이 발생하지 않는 것입니다. + +**Reconnect·Resume** + +```text +resumeToken +lastAppliedSequence +durable event cursor +deduplication +gap detection +snapshot fallback +``` + +완료 조건은 Node A 강제 종료 후 Node B/C에 reconnect해도 중복 없이 최신 state로 수렴하는 것입니다. + +**Multi-node·STOMP** + +Raw protocol의 cluster path를 먼저 검증한 뒤 STOMP adapter를 붙이는 것이 좋습니다. + +```text +External Session Index +Fan-out adapter +STOMP 1.2 +Simple Broker local profile +External Broker Relay +User Destination +Broker outage +``` + +Spring Simple Broker는 clustering에 적합하지 않으므로 multi-node Stable 여부는 external fan-out 또는 Broker Relay 시험으로 판단해야 합니다. citeturn19search1turn20search0 + +**고급·호환 기능** + +마지막에 다음을 추가합니다. + +```text +Protobuf +CBOR +permessage-deflate +Outbound WebSocket Client +SockJS +HTTP/2 WebSocket +HTTP/3 WebSocket +Admin Plane +``` + +각 기능은 기본 Starter를 비대하게 만들지 않고 별도 module/profile로 승격합니다. Compression은 RFC 7692 협상·memory control과 실제 CPU/heap benchmark가 완료된 Endpoint만 활성화해야 합니다. citeturn22search0 + +### 최종 플랫폼 계약 + +이번 조사 결과를 가장 압축해서 표현하면 다음과 같습니다. + +```text +WebSocket Runtime이 보장하는 것 += +연결 수명 ++ 인증된 Connection Context ++ Typed Protocol 진입 ++ bounded resource usage ++ serialized outbound write ++ heartbeat / disconnect ++ execution evidence 관측 ++ reconnect / resume orchestration +``` + +그러나 다음은 보장하지 않습니다. + +```text +WebSocket Frame 전송 +≠ Business Commit + +Business Commit +≠ Response Delivery + +Response Delivery +≠ Client Applied + +Connection Sequence +≠ Business Idempotency + +STOMP RECEIPT +≠ Transaction Commit + +STOMP ACK +≠ 보편적인 Durable Exactly-once + +WebSocket Reconnect +≠ Stream Resume + +Simple Broker +≠ Clustered Durable Broker + +Presence OPEN +≠ 사용자가 실제 Online이라는 절대 사실 +``` + +WebSocket 자체와 Spring의 low-level API가 제공하지 않는 이 의미들을 플랫폼이 명시적으로 분리해야 합니다. WebSocket은 content semantics를 규정하지 않는 transport이고, STOMP 역시 Destination과 reliability의 실제 의미를 server implementation에 맡기며, Spring Simple Broker 또한 ACK·Receipt와 clustering에 한계가 있습니다. citeturn17search0turn21search0turn19search1 + +따라서 최종 권고 구조는 다음입니다. + +```text + ┌───────────────────────┐ + │ HTTP / Web │ + │ Handshake, Proxy, Auth │ + └───────────┬───────────┘ + │ 101 + ▼ +┌─────────────────────────────────────────────────────┐ +│ WebSocket Platform │ +│ │ +│ Connection Context │ Session │ Budget │ Heartbeat │ +│ Security │ Ordering │ Backpressure │ Evidence │ +│ Observability │ Drain │ Reconnect Coordination │ +└───────────────┬───────────────────┬─────────────────┘ + │ │ + ┌────────▼────────┐ ┌──────▼─────────┐ + │ Raw Typed JSON │ │ STOMP Adapter │ + │ / Protobuf │ │ Broker Relay │ + └────────┬────────┘ └──────┬─────────┘ + │ │ + └────────┬──────────┘ + ▼ + Application Use Case + │ + ┌──────────────┼──────────────┐ + ▼ ▼ ▼ + JPA/Mongo Messaging Redis + Commit Replay/DLQ Registry/TTL + │ │ + └───────┬──────┘ + ▼ + Durable Execution Evidence + │ + ▼ + WebSocket Live Delivery +``` + +이 모델에서 **WebSocket은 “실시간 전달”을 소유하고, Application은 “상태 변경의 진실”을 소유하며, Messaging은 “내구성 있는 이벤트 이력”을 소유합니다.** 그 경계가 지켜져야 `APPLICATION_COMMITTED`, `RESPONSE_NOT_OBSERVED`, `CLIENT_APPLIED`, `RESUME_FROM_SEQUENCE`를 서로 혼동하지 않고 질문하신 핵심 문제—“서버에 도착했는가, 커밋됐는가, 프레임이 나갔는가, 클라이언트가 적용했는가, 어디부터 재개할 수 있는가”—에 각각 독립적인 증거로 답할 수 있습니다. \ No newline at end of file diff --git a/docs/websocket-superpowers-package/validate_websocket_docs.py b/docs/websocket-superpowers-package/validate_websocket_docs.py new file mode 100644 index 00000000..5689425c --- /dev/null +++ b/docs/websocket-superpowers-package/validate_websocket_docs.py @@ -0,0 +1,84 @@ +from __future__ import annotations +from pathlib import Path +import re +import sys +import zipfile + +root = Path(__file__).resolve().parent +if (root / 'docs').exists(): + design = root / 'docs/superpowers/specs/2026-08-14-websocket-realtime-connection-platform-design.md' + stable = root / 'docs/superpowers/plans/2026-08-14-websocket-realtime-connection-platform-implementation-plan.md' + advanced = root / 'docs/superpowers/plans/2026-08-14-websocket-advanced-capabilities-expansion-plan.md' +else: + design = Path('/mnt/data/websocket-realtime-connection-platform-design.md') + stable = Path('/mnt/data/websocket-realtime-connection-platform-implementation-plan.md') + advanced = Path('/mnt/data/websocket-advanced-capabilities-expansion-plan.md') + +checks = [] +def check(name, condition): + checks.append((name, bool(condition))) + +for path in (design, stable, advanced): + check(f'exists:{path.name}', path.exists()) + if not path.exists(): + continue + text = path.read_text(encoding='utf-8') + check(f'code-fence:{path.name}', text.count('```') % 2 == 0) + check(f'no-placeholders:{path.name}', not re.search(r'\b(TODO|TBD|FIXME)\b', text)) + check(f'nontrivial:{path.name}', len(text.splitlines()) > 300) + +stable_text = stable.read_text(encoding='utf-8') +advanced_text = advanced.read_text(encoding='utf-8') +design_text = design.read_text(encoding='utf-8') + +stable_tasks = [int(n) for n in re.findall(r'^### Task (\d+):', stable_text, re.M)] +advanced_tasks = [int(n) for n in re.findall(r'^### Task (\d+):', advanced_text, re.M)] +check('stable-task-sequence', stable_tasks == list(range(1, 54))) +check('advanced-task-sequence', advanced_tasks == list(range(1, 23))) + +for label, text, expected in [('stable', stable_text, 53), ('advanced', advanced_text, 22)]: + sections = re.split(r'(?=^### Task \d+:)', text, flags=re.M)[1:] + check(f'{label}-task-count', len(sections) == expected) + for idx, section in enumerate(sections, 1): + for token in ['**Files:**', '**Interfaces:**', '**Implementation requirements:**', + 'Step 1:', 'Step 2:', 'Step 3:', 'Step 4:', 'Step 5:', + 'git commit -m']: + check(f'{label}-task-{idx}-{token}', token in section) + +create_pattern = re.compile(r'^- Create: `([^`]+)`', re.M) +stable_paths = create_pattern.findall(stable_text) +advanced_paths = create_pattern.findall(advanced_text) +check('stable-create-unique', len(stable_paths) == len(set(stable_paths))) +check('advanced-create-unique', len(advanced_paths) == len(set(advanced_paths))) +check('stable-advanced-create-disjoint', set(stable_paths).isdisjoint(set(advanced_paths))) + +required_design = [ + 'Inbound Evidence', 'Outbound Evidence', 'Connection Evidence', + 'hyeonworks.realtime.v1.json', 'ONE_TIME_TICKET', + 'APPLICATION_COMMITTED', 'WRITTEN_LOCALLY', + 'Outbound Queue·Backpressure', 'Nginx', 'Tomcat', 'Jetty', + 'Reactor Netty', 'WebSocket exactly-once', 'Appendix A' +] +for token in required_design: + check(f'design-token:{token}', token in design_text) + +required_stable = [ + 'Commit 후 Response Loss', 'Slow Consumer', 'Browser Matrix', + 'MVC·WebFlux Stack 상호 배타성', 'Stable Release Gate' +] +for token in required_stable: + check(f'stable-token:{token}', token in stable_text) + +required_advanced = [ + 'Resume Token', 'Messaging 기반 Durable Replay', 'STOMP 1.2', + 'RabbitMQ STOMP Broker Relay', 'HTTP/3 WebSocket Experimental', + 'GraphQL WebSocket Transport Bridge' +] +for token in required_advanced: + check(f'advanced-token:{token}', token in advanced_text) + +failed = [name for name, ok in checks if not ok] +print(f'checks={len(checks)} passed={len(checks)-len(failed)} failed={len(failed)}') +for name in failed: + print('FAIL', name) +sys.exit(1 if failed else 0) diff --git a/docs/websocket/advanced-support-matrix.md b/docs/websocket/advanced-support-matrix.md new file mode 100644 index 00000000..8db3f604 --- /dev/null +++ b/docs/websocket/advanced-support-matrix.md @@ -0,0 +1,47 @@ +# WebSocket Advanced: support matrix + +Every capability is off unless named. This table is what each one costs and what has to be true +before it is promoted. `AdvancedPromotionGate.forCapability` is the machine-checked form of the last +two columns; if they disagree, the code wins and this table is stale. + +| Capability | Flag | Adds | Required suites | Soak | +| --- | --- | --- | --- | --- | +| `RESUME` | `…advanced.resume.enabled` | A signed token and a replay store | `websocket:test`, `websocketJettyTest`, `resume-history-loss`, `resume-replay` | 8h | +| `CLUSTER_REDIS` | `…advanced.cluster-redis.enabled` | A Redis dependency on the routing path | `websocket:test`, `multi-node-fanout`, `node-loss`, `index-partition` | 24h | +| `CLUSTER_MESSAGING` | `…advanced.cluster-messaging.enabled` | A broker dependency on the delivery path | as `CLUSTER_REDIS` | 24h | +| `PRESENCE` | `…advanced.presence.enabled` | A read model over the cluster index | as `CLUSTER_REDIS` | 24h | +| `STOMP` | `…advanced.stomp.enabled` | A second protocol parser, pre-authentication | `websocket:test`, `broker-outage`, `broker-reconnect`, `user-destination` | 8h | +| `BROKER_RELAY_RABBIT` | `…advanced.stomp.relay.enabled` | A TCP dependency on an external broker | as `STOMP` | 8h | +| `CODEC_PROTOBUF` | `…advanced.codec-protobuf.enabled` | A second decode path | `websocket:test`, `websocketJettyTest` | 8h | +| `CODEC_CBOR` | `…advanced.codec-cbor.enabled` | A second decode path | `websocket:test`, `websocketJettyTest` | 8h | +| `COMPRESSION` | `…advanced.compression.enabled` | Per-connection memory, and a length side channel | `websocket:test`, `decompression-bound`, `memory-under-load` | 24h | +| `OUTBOUND_CLIENT` | `…advanced.outbound-client.enabled` | Long-lived connections this service initiates | `websocket:test`, `websocketJettyTest` | 8h | +| `SOCKJS_COMPAT` | `…advanced.sockjs.enabled` | Credentialed cross-origin HTTP, so CSRF | `websocket:test`, `websocketJettyTest` | 8h | +| `HTTP2_COMPAT` | `…advanced.http2.enabled` | RFC 8441 extended CONNECT | `websocket:test`, `websocketNginxTest`, `proxy-matrix`, `classic-upgrade-fallback` | 7d | +| `HTTP3_EXPERIMENTAL` | `…advanced.http3.enabled` | QUIC, and every hop problem HTTP/2 has | as `HTTP2_COMPAT` | 7d | +| `GRAPHQL_TRANSPORT` | `…advanced.graphql-transport.enabled` | A subprotocol on the Stable runtime | `websocket:test`, `websocketJettyTest` | 8h | + +## What is *not* supported, and why the row is here + +| Not supported | Reason | +| --- | --- | +| Simple broker in a multi-node deployment | It does not error. It delivers to whichever fraction of users is on the publishing node, which reads as intermittent loss. `SimpleBrokerProfile.activatableUnder` refuses it. | +| `permessage-deflate` on an endpoint mixing a secret with attacker-influenced content | The CRIME/BREACH shape. No parameter combination makes it safe; the leak is in the compressed length. `CompressionPolicy.mayCompress` refuses it. | +| JSONP polling on a sensitive endpoint | JSONP executes server-supplied script in the page. | +| Protobuf payloads above 256KB | The broker, the replay store and every in-memory queue would each hold the message whole. | +| Extended CONNECT on an untested hop | RFC 8441 fails by killing the connection, not by negotiating a fallback. | +| HTTP/3 as stable support | `Http3ExperimentalProfile.stableSupport()` returns false, always. | +| Both STOMP channels at once | The broker that results is whichever configurer ran last, with no error. `StompBrokerExclusivity` refuses it. | + +## Promotion + +Each capability is promoted on its own evidence. They share a feature-flag mechanism and nothing +else, so promoting them together means the evidence for the cheapest is treated as evidence for the +most dangerous. + +Two conditions apply to all of them and are not waivable: + +- **Rollback exercised.** A flag nobody has turned off is not known to turn off. +- **Stable artifact unchanged.** If enabling the capability changed Stable's wire contract or + dependency graph, Stable was never independent of it, and deployments that did not enable it are + affected anyway. diff --git a/docs/websocket/repository-adaptation.md b/docs/websocket/repository-adaptation.md new file mode 100644 index 00000000..a24ea941 --- /dev/null +++ b/docs/websocket/repository-adaptation.md @@ -0,0 +1,180 @@ +# WebSocket platform: how the design maps onto this repository + +The design models the platform as eighteen Gradle modules under `modules/websocket`. This +repository's fail-closed registry (`src/config/architecture/modules.json`) owns the leaf list, so +those modules are packages inside the registered `:adapter:inbound:websocket` leaf — the same +resolution the JPA, GraphQL and web platforms reached. + +That is only honest if the boundaries are machine-checked, so `WebSocketStableModule` declares each +module's package, its purity grade and its exact allowed edges, and `WebSocketModuleBoundaryTest` +scans the source tree and fails when the two disagree in either direction. Promoting a package to +its own Gradle leaf later is a registry edit rather than an archaeology exercise. + +## Where the module map deviates from the design, and why + +| Design places it in | Here | Reason | +| --- | --- | --- | +| `WebSocketSubprotocolName` in `websocket-protocol` | `core` | The connection context must name the negotiated token, and the context is core — leaving the type in `protocol` made `core` depend on `protocol` while `protocol` already depended on `core`. The boundary test refused the cycle. The negotiation *policy* stays in `protocol`. | +| `StrictWebSocketJsonCodec` in `websocket-protocol` | `codec` (its own FRAMEWORK_BOUND module) | `protocol` is CORE here, and a Jackson import in a CORE module is refused. Splitting is better than relaxing the rule: the envelope's field rules stay testable with no mapper, and everything that touches a parser sits in one package a reviewer can read end to end. | +| `budget -> core` | `core -> budget` | `budget` imports nothing from `core` — numbers depend on nothing. The endpoint profile, which is core, has to name a budget. The declared direction was simply backwards. | +| — | `stomp` module | The pre-existing STOMP-over-SockJS channel predates this platform and still ships. Declared so the boundary is complete rather than excused; it has no edge to any platform module and none to it. | + +## What the design's rules actually prevent + +A few of the design's requirements read as style and are not. These are the ones worth keeping. + +**`websocket-core-api` names no framework.** Stricter here than in the HTTP platform, because a +connection is a long-lived object owned by a container and reaching for the container's own session +type is tempting from everywhere. A CORE module has no `WebSocketSession`, so the same decision +serves both runtimes and is testable without a server. The detector's framework list was missing +`tools.jackson` (this repo runs Jackson 3, not 2) — a CORE module could have imported a mapper +unnoticed. Fixed in both this leaf and the web leaf. + +**No Java class name on the wire.** A FQCN publishes the package layout, breaks every client on a +rename, and makes the receiver's type resolution an attack surface. `WebSocketMessageType` refuses +anything that looks like one; the manifest binds published names to records, and records cannot run +code while being populated. + +**The payload is an encoded string, not a `Map`.** A map accepts any shape, defers validation to +whichever handler reaches for a missing key, makes an entity trivially serializable onto the wire, +and brings unbounded nesting with it. + +**Handlers cannot write.** `WebSocketHandlerContext` has no session and no write method. This is +what makes ordering, backpressure and the drain sequence guarantees rather than conventions — a +handler that could write directly would bypass the queue, and every promise would hold only for the +handlers that cooperated. + +## Two findings from building it + +**Tomcat's graceful shutdown does not close WebSocket connections.** It waits for in-flight +*requests*, and an established WebSocket is not a request — so the shutdown completes with the +connections still open and they die when the socket is torn down. The client sees a **1006**, +indistinguishable from a network failure, which sends it into its most aggressive reconnect path at +exactly the moment the fleet is restarting. `PlatformWebSocketHandler` therefore implements +`SmartLifecycle` and closes its own connections with **1001 going away**, at a phase that runs +before the web server stops. Found by `TomcatWebSocketAbuseIT`, which failed on its first run. + +**Both runtimes on one classpath is a silent outage.** Spring Boot deduces one application type +from what is present, and picks the servlet one. A deployment that declared reactive endpoints and +shipped both starts, reports healthy, and never answers. `WebSocketStackExclusivity` reads what the +classpath will actually produce and fails startup with a sentence explaining it. + +## Lanes + +```bash +cd src +./gradlew :adapter:inbound:websocket:test # unit, boundary, architecture, Tomcat runtime +./gradlew :adapter:inbound:websocket:websocketJettyTest # the second servlet container +./gradlew :adapter:inbound:websocket:websocketNginxTest # real Nginx; needs Docker, fails without it +``` + +The Nginx lane carries a deliberately broken configuration alongside the correct one +(`nginx-no-upgrade.conf`) so the lane proves its own assertions can fail. A contract that only ever +runs against a correct configuration cannot tell whether it is checking anything. + +## The Stable behaviours that had no code + +A late audit compared every named type in the Stable plan against the source tree rather than +against memory, and found six that nothing implemented. They are listed because the way they were +missed is more useful than the fact that they were: each is a *behaviour under failure*, and the +plan named it inside a task whose other half was already built — so the task read as done. + +| Behaviour | Where it lives now | +| --- | --- | +| A queue observation an operator can read, separating a configured drop from a lossless overflow | `outbound/OutboundQueueSnapshot` + `OutboundQueue.snapshot()` | +| A timed-out correlation id that is remembered, so a late answer is not attached to a reused id | `handler/LateResponseTombstone` | +| Pooled-buffer retention on the reactive stack, bounded and counted | `webflux/WebSocketDataBufferPolicy` + `WebSocketDataBufferLifecycle` | +| What a proxy in front of this platform has to do | `release/WebSocketNginxProxyProfile` | +| What a rolling restart has to demonstrate, in order | `release/WebSocketRollingRestartScenario` | +| What the platform must show before promotion | `release/WebSocketStableReleaseGate` | + +The three `release` types share a module identity (`RELEASE`, CORE) that names no other module. +That is deliberate: a gate importing the parts it gates would be satisfiable by construction — the +evidence and the checklist would come from the same source. They are predicates over facts a release +engineer supplies, so a missing runtime stays a missing runtime. + +`WebSocketStableReleaseGate` overlaps `advanced/release/AdvancedPromotionGate` in shape and differs +in one condition that matters. Advanced capabilities are off unless a deployment names them, so a +broken one affects whoever enabled it; Stable is what every deployment gets, so its evidence has to +cover every runtime it claims — Tomcat, Jetty, Reactor Netty and Nginx — rather than the one the +author happened to test. It also refuses to promote an artifact containing an Advanced type. WS-ARCH-6 +already refuses that as a source edge; nothing in it notices a type that arrived through packaging, +and the effect is identical — Stable that does not build without Advanced is a naming convention. + +## Not yet implemented + +**Tasks 47–48, the browser matrix.** The design asks for a protocol test client driven through +Chromium, Firefox and WebKit. Not built: it needs Playwright and a browser download per engine, +which is a dependency and a network requirement this template does not otherwise carry, and a +browser lane that silently skips when the browsers are absent is worth less than no lane. The +properties it would cover that the Java client does not — that a browser cannot set headers on the +`WebSocket` constructor, and that it surfaces close codes to page script — are the reasons +`ONE_TIME_TICKET` and the standard close codes exist, and both are asserted at the unit level. + +## Advanced capabilities + +The Advanced expansion plan asks for fifteen Gradle modules under `modules/websocket-advanced/`. +They are packages under `advanced.**` in this leaf, for the same reason the Stable platform is — +`src/config/architecture/modules.json` is fail-closed and owns the leaf list, and a fifteen-leaf +addition to satisfy a directory layout is a change to the registry, not to the architecture. The +separation the design wanted is enforced by `WebSocketStableModule` and by WS-ARCH-6, which fails +the build when a Stable class names an Advanced one. A feature flag decides whether a bean exists; +it does nothing about a compile-time edge, and ArchUnit does. + +Three of the fifteen needed their own module identity rather than sharing `advanced`: + +- **`advanced-stomp`** (`advanced.stomp`) is `FRAMEWORK_BOUND`. STOMP here *is* Spring Messaging, and + folding it into `advanced` would have relaxed that module's purity for every capability in it. +- **`advanced-stomp-rabbit`** (`advanced.stomp.rabbit`) is separate again. The adapter parses a + protocol; the relay opens a TCP connection to somebody else's broker and makes every delivery + depend on it. Different blast radius, so a deployment can refuse one and keep the other. +- Everything else resolves to `advanced` by longest-prefix, which is what lets `advanced.codec.cbor` + exist without its own edge set. + +### What was adapted rather than copied + +**Task 8's presence record shape.** The design specifies +`(actorFingerprint, activeConnectionCount, state, lastObservedAt)` with a four-state observation +model. Implemented with `WebSocketActorReference` in place of a bare fingerprint string — it carries +the fingerprint and refuses to be constructed from raw identity, which is the property the design +was buying with the field name. The four states are implemented as specified; `PresenceState.STALE` +and `OFFLINE` are distinct because collapsing them reports every user as disconnected during a Redis +partition, when the connections are fine and the index went dark. + +**Task 14's Protobuf codec.** The descriptor compatibility gate and the profile are implemented and +tested. The encode/decode path is not: a Protobuf codec without generated message classes has +nothing to encode, and generating them requires a `.proto` source this template does not have and +should not invent. `DescriptorCompatibilityGate` is the part with the failure mode worth guarding — +a changed or reused field number reinterprets bytes already on the wire, and during a rolling deploy +both descriptor versions are live, so the receiver reads the wrong field without erroring. + +**Task 15's CBOR codec.** Same shape, same reason: `CborCodecProfile` fixes canonical encoding and +the duplicate-key policy, which are the two settings that decide whether two systems reading the +same bytes agree. `WebSocketCborCodec` implements the encode/decode path on top of it, and +`SchemaParity` is what stops it publishing a different catalog than JSON. + +`jackson-dataformat-cbor` and `protobuf-java` are `compileOnly` plus `testImplementation`, not +`implementation`. Both jars change an adopter's behaviour by their mere presence — Spring Boot +registers a `cborMapper` bean for the first and Spring registers a Protobuf message converter for +the second — so an adopting composition root would acquire both without enabling either capability. +The sibling web leaf shipped exactly that mistake and it broke the composition root outright; see +`docs/web/repository-adaptation.md`. `WebSocketBinaryCodecBackend` turns an absent backend into a +sentence naming the missing coordinate, and `BinaryCodecBackendScopeTest` reads `build.gradle` and +fails if either returns to `implementation`. + +**Task 12's relay configuration.** `RabbitBrokerRelayConfiguration` contributes only the broker, not +the destination prefixes. Two `WebSocketMessageBrokerConfigurer` beans each setting the application +prefix produce whichever ran last, silently — the same failure `StompBrokerExclusivity` exists to +catch between the two STOMP channels. + +### Verification + +```bash +cd src +./gradlew :adapter:inbound:websocket:test # 509 tests, Advanced included +./gradlew :adapter:inbound:websocket:websocketJettyTest +./gradlew :adapter:inbound:websocket:websocketNginxTest +``` + +See `docs/adr/ADR-WS-002-resume-and-cluster.md`, `docs/adr/ADR-WS-003-stomp-and-broker-relay.md`, +`docs/websocket/advanced-support-matrix.md` and `docs/websocket/runbooks/`. diff --git a/docs/websocket/runbook.md b/docs/websocket/runbook.md new file mode 100644 index 00000000..e4933ba9 --- /dev/null +++ b/docs/websocket/runbook.md @@ -0,0 +1,107 @@ +# WebSocket platform runbook + +Organised by what you observe, because that is what you have during an incident. + +## Clients report 1006 — "closed abnormally, no reason" + +The most important symptom here, because 1006 is indistinguishable from a network failure and +drives every client into its most aggressive reconnect path. + +- **During a deploy** — the platform did not close its connections. `PlatformWebSocketHandler` + implements `SmartLifecycle` and closes with **1001 going away**; a container's own graceful + shutdown does **not** do this, because it waits for in-flight *requests* and an established + WebSocket is not a request. Check the handler bean is registered and that its phase runs before + the web server stops. +- **Behind a proxy** — the upgrade is mishandled, so the close frame never becomes a close frame. + See the next section. +- **Neither** — something threw while closing. A close reason longer than 123 bytes makes the + container throw mid-close and turns a clean refusal into a 1006; the platform truncates for that + reason. + +## Connections work locally and not behind the load balancer + +Three nginx directives, each producing a different failure: + +| Missing | Symptom | +| --- | --- | +| `proxy_http_version 1.1` | Handshake answered 400, or simply not upgraded. HTTP/1.0 has no Upgrade mechanism. | +| `Upgrade` / `Connection` forwarded | Upstream sees an ordinary GET and answers 404 or 200 for a route that works when tested directly. They are hop-by-hop headers, so a proxy is *required* to drop them. | +| `proxy_read_timeout` raised | Every connection quieter than 60s is killed with no close frame. A WebSocket is idle by nature. | + +`websocketNginxTest` asserts all three against a real Nginx, and carries a deliberately broken +configuration so it proves its own assertions can fail. + +## Connections vanish with no close frame and no error + +Almost always an intermediary's idle timeout, not the application. TCP does not report a departed +peer — a closed laptop lid, a phone switching to cellular and a NAT forgetting its mapping all +produce no FIN. + +- Check `HeartbeatPolicy`: the ping interval must be under ~20s, because intermediaries commonly + drop idle connections at 30–60s and say nothing. +- The idle timeout must be at least two ping intervals. Below that, one dropped ping on a congested + network closes a healthy connection, and the reconnect storm makes the congestion worse. + +## Memory grows and nothing is failing + +A slow consumer. It does not error — it reads more slowly than the server writes and the difference +accumulates. Arranging it requires no tooling: reading slowly is enough. + +- **Check first:** `WebSocketNodeSnapshot.droppedOutboundMessages`. Connection counts and buffered + bytes both look healthy while data is being lost; the drop count is the only number that says so. +- **Bounds:** per connection (`maxBufferedOutboundBytes`) *and* node-wide (`GlobalBufferBudget`). + The second is not redundant — a megabyte each is fine at a hundred connections and is the whole + heap at fifty thousand. +- **If drops are zero and memory still grows:** check that the queue lock and the socket lock are + separate. When they were one lock, a stalled peer blocked every producer, the queue never filled, + and the shedding bound never fired. That is fixed, and it is the shape to look for if it recurs. + +## A command ran twice + +The reconnect is what makes this a WebSocket problem: a client replays everything it never saw an +answer for, on a new connection, all at once. + +1. **Key scoped to the connection?** It must not be. `WebSocketCommandKey` is endpoint + actor + + the client's own message id, so it survives the reconnect. +2. **Ledger not transactional?** The only implementation that works writes in the application's own + transaction. Anything else has a window between the business commit and the ledger write. +3. **Outcome `UNKNOWN`?** That is the window. Neither replaying nor re-running is safe — replaying + invents a result, re-running duplicates a committed write. Only `CommandReconciliation` reading + the business data can settle it, and `INDETERMINATE` is a real answer to act on. + +## A client cannot connect and the endpoint is definitely there + +- **403** — origin. The same-origin policy does **not** protect a WebSocket handshake and there is + no preflight, so the server's `Origin` check is the entire defence and it is strict: exact match, + lowercase, no path, no wildcard. +- **401** — the ticket was spent, expired (30s cap), or minted for another endpoint. All three are + deliberate; a longer-lived or unbound ticket is a bearer token in a query string again. +- **400** — no offered subprotocol is supported. Production refuses a client that offers none. +- **503** — the endpoint is at its connection cap. + +Order matters and is asserted: route → origin → capacity → credential → subprotocol. Origin is +checked before authentication because a cookie handshake from an attacker's page authenticates +perfectly; the credential is valid and the page is not allowed to use it. + +## Endpoints never answer and the app reports healthy + +Both runtimes on one classpath. Spring Boot deduces one application type and picks the servlet one, +so declared reactive endpoints are simply never served — no error, no warning. +`WebSocketStackExclusivity` fails startup on this; if a running instance has it, the check is not +wired. + +## Metrics stopped arriving + +A high-cardinality tag, and the risk is worse than for HTTP: a request produces one observation, +a connection produces them for hours, so the series outlive the connections and only accumulate. +`WebSocketMetricTags` allows eight names; `connectionId`, `sessionId` and `actor` are not among +them. `nodeId` is, because a fleet has a knowable number of nodes. + +## Verification + +```bash +cd src +./gradlew :adapter:inbound:websocket:test # unit, boundary, architecture, Tomcat runtime +./gradlew :adapter:inbound:websocket:websocketJettyTest # second servlet container +./gradlew :adapter:inbound:websocket:websocketNginxTest # real Nginx; needs Docker, fails without it +``` diff --git a/docs/websocket/runbooks/broker-outage.md b/docs/websocket/runbooks/broker-outage.md new file mode 100644 index 00000000..bde26018 --- /dev/null +++ b/docs/websocket/runbooks/broker-outage.md @@ -0,0 +1,55 @@ +# Runbook: STOMP broker outage + +Applies when `advanced.stomp.relay` is enabled and the RabbitMQ broker becomes unreachable. + +## What the symptom looks like + +Not an error rate. The relay holds one TCP connection to the broker plus one per authenticated +session, and a broker that stops responding without closing leaves all of them **open**. The +platform's own health check stays green; connections stay established; publishes are accepted. + +The first real signal is one of: + +- subscriptions established but no `MESSAGE` frames arriving, for everyone at once; +- the relay's system heartbeat failing to receive (`systemHeartbeatReceiveInterval` elapsed); +- new sessions failing to subscribe while existing ones appear fine — this is the broker's + connection limit, not the outage itself. + +If the heartbeat is not configured, none of the above fires and the first signal is a user report. +`RabbitBrokerRelayProfile` refuses a zero heartbeat for exactly this reason. + +## Triage + +1. **Confirm the direction.** From a platform node, open a STOMP connection to the broker's host and + port directly. If that succeeds, the problem is the relay's connection state, not the broker. +2. **Check the connection count against the broker's limit.** `brokerConnectionsFor(sessions)` is + `sessions + 1`. A broker at its limit refuses new connections and serves existing ones, which + produces the "new users cannot subscribe" shape. +3. **Check whether messages are being accepted.** A half-open connection accepts every publish + silently. Publishes succeeding is not evidence the broker is alive. + +## Recovery + +- **Broker restarted, relay did not reconnect.** The relay reconnects on its own; if it has not + within two heartbeat intervals, restart the platform nodes one at a time. Do not restart them all + at once — every session reconnects simultaneously and the broker meets its whole client population + in one instant. +- **Broker at its connection limit.** Raise the limit or shed sessions. Shedding is the faster of + the two and the connection count falls with the sessions. +- **Broker gone and not coming back.** There is no safe fallback to the simple broker in a + multi-node deployment: it delivers to whichever fraction of users is on the publishing node. + `SimpleBrokerProfile.activatableUnder` refuses it outside local/test, and that refusal should not + be overridden during an incident. Scale to a single node first if the simple broker is the only + option. + +## What is lost + +Anything the broker held and did not persist. `StompEvidence.BROKER_ACK` is only durable if the +broker is durable, and RabbitMQ's durability is a property of the queue topology, not of the relay. +Messages acknowledged at `PROTOCOL_RECEIPT` were never in the broker at all. + +## Afterwards + +- If the heartbeat did not fire first, that is the finding. Fix it before the postmortem closes. +- If the connection limit was reached, record the session count that reached it. It is a hard + ceiling on the deployment and it is not otherwise written down anywhere. diff --git a/docs/websocket/runbooks/resume-history-loss.md b/docs/websocket/runbooks/resume-history-loss.md new file mode 100644 index 00000000..a95654fa --- /dev/null +++ b/docs/websocket/runbooks/resume-history-loss.md @@ -0,0 +1,56 @@ +# Runbook: resume history loss + +Applies when `advanced.resume` is enabled and clients present resume tokens the replay store can no +longer satisfy. + +## What the symptom looks like + +Clients reconnecting and resynchronising rather than resuming. This is the **designed** behaviour, +not a fault — `ResumeCoordinator` consults `ReplayAvailability` and refuses to honour a position the +store has evicted, because delivering a stream with a hole in it is worse than an explicit +resynchronise. + +It becomes an incident when the resynchronise rate is high enough to matter: + +- a resynchronise means the client re-reads its whole state, so a spike is a load spike on whatever + serves that state; +- for a client that cannot resynchronise cheaply, it is user-visible as a stall. + +## Triage + +1. **Establish which of the three causes it is.** + - *Store eviction under load.* The replay store's retention is shorter than the disconnect + durations being seen. Look at retention against reconnect latency, not against a nominal + figure. + - *Store restarted or partitioned.* Availability drops to nothing and every token fails at once. + - *Key rotation.* `ResumeTokenKeyRing` verifies against retired keys as well as the current one; + if a key was removed rather than retired, every token minted under it fails to verify. This + produces the same symptom and a different fix. + + `ResumeTokenOutcome` distinguishes these. A token that fails verification is not the same as one + that verifies and names an evicted position. + +2. **Check whether the resynchronise is succeeding.** A high resynchronise rate that completes is a + capacity problem. One that fails is a correctness problem and is more urgent. + +## Recovery + +- **Eviction under load.** Raise retention if the store can hold it. Retention is bounded by memory, + so this trades against the store's own stability — do not raise it past what the store survives. +- **Store restarted.** Nothing to recover; the tokens are genuinely unsatisfiable. Let clients + resynchronise. If the resynchronise load is the problem, shed connections so they arrive in + batches rather than all at once. +- **Key removed rather than retired.** Restore the key to the ring as a verify-only entry. Minting + continues under the current key. + +## What is lost + +Nothing that was acknowledged. Resume is an optimisation over resynchronise; the client's ability to +rebuild its state from the authoritative source is the actual guarantee, and it is unaffected. + +## Afterwards + +- If retention was the cause, record the disconnect duration distribution that exceeded it. The + nominal retention figure is meaningless without it. +- If a key was removed, that is a process finding, not a platform one. Keys are retired, never + deleted, and the ring is the place that is enforced. diff --git a/src/adapter/inbound/graphql/build.gradle b/src/adapter/inbound/graphql/build.gradle index 7b7cc4bc..42cebcf2 100644 --- a/src/adapter/inbound/graphql/build.gradle +++ b/src/adapter/inbound/graphql/build.gradle @@ -72,6 +72,10 @@ dependencies { // a test-only authentication/CORS composition. Security remains a composition-root concern; // this dependency does not add production security policy to the opt-in GraphQL adapter. testImplementation 'org.springframework.boot:spring-boot-starter-security' + // TestRestTemplate needs RestTemplateBuilder, and spring-boot-resttestclient stopped + // bringing it transitively in Spring Boot 4.0.x — the capability is still supported, its + // dependency is simply no longer implicit. A module that autowires TestRestTemplate says so. + testImplementation 'org.springframework.boot:spring-boot-restclient' // A real MeterRegistry, so the cardinality claim is measured rather than asserted. Only // micrometer-observation is on the production classpath; a registry that actually stores series diff --git a/src/adapter/inbound/graphql/gradle.lockfile b/src/adapter/inbound/graphql/gradle.lockfile index 28d2aeea..74f612c8 100644 --- a/src/adapter/inbound/graphql/gradle.lockfile +++ b/src/adapter/inbound/graphql/gradle.lockfile @@ -2,19 +2,18 @@ # Manual edits can break the build and are not advised. # This file is expected to be part of source control. biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,testCompileClasspath,testFixturesCompileClasspath -ch.qos.logback:logback-classic:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath -ch.qos.logback:logback-core:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath -com.fasterxml.jackson.core:jackson-annotations:2.20=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath -com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor +ch.qos.logback:logback-classic:1.5.38=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +ch.qos.logback:logback-core:1.5.38=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-annotations:2.21=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs -com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,testCompileClasspath,testFixturesCompileClasspath com.github.spotbugs:spotbugs:4.10.2=spotbugs com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor -com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,spotbugs,testCompileClasspath,testFixturesCompileClasspath +com.google.code.findbugs:jsr305:3.0.2=checkstyle,spotbugs com.google.code.gson:gson:2.13.2=spotbugs com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,testCompileClasspath,testFixturesCompileClasspath @@ -33,27 +32,27 @@ com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProce com.graphql-java:graphql-java:25.0=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath com.graphql-java:java-dataloader:6.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins -com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath +com.jayway.jsonpath:json-path:2.10.0=testCompileClasspath,testRuntimeClasspath com.puppycrawl.tools:checkstyle:13.5.0=checkstyle com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath commons-beanutils:commons-beanutils:1.11.0=checkstyle commons-collections:commons-collections:3.2.2=checkstyle commons-io:commons-io:2.21.0=spotbugs -commons-logging:commons-logging:1.3.5=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +commons-logging:commons-logging:1.3.6=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath info.picocli:picocli:4.7.7=checkstyle io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor -io.micrometer:context-propagation:1.2.0=runtimeClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath -io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath -io.micrometer:micrometer-core:1.16.0=testCompileClasspath,testRuntimeClasspath -io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath -io.projectreactor:reactor-core:3.8.0=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +io.micrometer:context-propagation:1.2.1=runtimeClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +io.micrometer:micrometer-commons:1.16.7=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +io.micrometer:micrometer-core:1.16.7=testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-observation:1.16.7=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +io.projectreactor:reactor-core:3.8.7=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath jakarta.activation:jakarta.activation-api:2.1.4=testCompileClasspath,testRuntimeClasspath jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath jakarta.servlet:jakarta.servlet-api:6.1.0=compileClasspath -jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=testCompileClasspath,testRuntimeClasspath +jakarta.xml.bind:jakarta.xml.bind-api:4.0.5=testCompileClasspath,testRuntimeClasspath javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor -jaxen:jaxen:2.0.0=spotbugs +jaxen:jaxen:2.0.6=spotbugs net.bytebuddy:byte-buddy-agent:1.17.8=testCompileClasspath,testRuntimeClasspath net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath net.minidev:accessors-smart:2.6.0=testCompileClasspath,testRuntimeClasspath @@ -66,19 +65,19 @@ org.apache.commons:commons-text:1.15.0=spotbugs org.apache.commons:commons-text:1.3=checkstyle org.apache.httpcomponents:httpclient:4.5.13=checkstyle org.apache.httpcomponents:httpcore:4.4.16=checkstyle -org.apache.logging.log4j:log4j-api:2.25.2=compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath -org.apache.logging.log4j:log4j-core:2.25.2=spotbugs -org.apache.logging.log4j:log4j-to-slf4j:2.25.2=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-api:2.25.5=compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-core:2.25.5=spotbugs +org.apache.logging.log4j:log4j-to-slf4j:2.25.5=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath org.apache.maven.doxia:doxia-core:1.12.0=checkstyle org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle -org.apache.tomcat.embed:tomcat-embed-core:11.0.14=testCompileClasspath,testRuntimeClasspath -org.apache.tomcat.embed:tomcat-embed-el:11.0.14=testCompileClasspath,testRuntimeClasspath -org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-core:11.0.24=testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-el:11.0.24=testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-websocket:11.0.24=testCompileClasspath,testRuntimeClasspath org.apache.xbean:xbean-reflect:3.7=checkstyle org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath -org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath +org.assertj:assertj-core:3.27.7=testCompileClasspath,testRuntimeClasspath org.awaitility:awaitility:4.3.0=testCompileClasspath,testRuntimeClasspath org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle @@ -88,15 +87,15 @@ org.dom4j:dom4j:2.2.0=spotbugs org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath org.hdrhistogram:HdrHistogram:2.2.2=testRuntimeClasspath org.javassist:javassist:3.28.0-GA=checkstyle -org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testFixturesAnnotationProcessor,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath -org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-engine:6.0.1=testRuntimeClasspath -org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath -org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath +org.jspecify:jspecify:1.0.1=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testFixturesAnnotationProcessor,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.3=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.3=testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.3=testRuntimeClasspath +org.junit:junit-bom:6.0.3=testCompileClasspath,testRuntimeClasspath org.junit:junit-bom:6.1.0=spotbugs org.latencyutils:LatencyUtils:2.0.3=testRuntimeClasspath org.mockito:mockito-core:5.20.0=mockitoAgent,testCompileClasspath,testRuntimeClasspath @@ -117,61 +116,63 @@ org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor, org.reactivestreams:reactive-streams:1.0.4=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath org.reflections:reflections:0.10.2=checkstyle org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath -org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath -org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath -org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j -org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-configuration-processor:4.0.0=annotationProcessor -org.springframework.boot:spring-boot-graphql-test:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-graphql:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-http-codec:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-http-converter:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-jackson:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-restclient:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-security:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-servlet:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-graphql-test:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-graphql:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-jackson:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-security:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-web:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-web-server:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-webtestclient:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath -org.springframework.graphql:spring-graphql-test:2.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.graphql:spring-graphql:2.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath -org.springframework.security:spring-security-config:7.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.security:spring-security-core:7.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.security:spring-security-crypto:7.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.security:spring-security-web:7.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework:spring-aop:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath -org.springframework:spring-beans:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath -org.springframework:spring-context:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath -org.springframework:spring-core:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath -org.springframework:spring-expression:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath -org.springframework:spring-test:7.0.1=testCompileClasspath,testRuntimeClasspath -org.springframework:spring-web:7.0.1=testCompileClasspath,testRuntimeClasspath -org.springframework:spring-webflux:7.0.1=testCompileClasspath,testRuntimeClasspath -org.springframework:spring-webmvc:7.0.1=testCompileClasspath,testRuntimeClasspath +org.slf4j:jul-to-slf4j:2.0.18=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +org.slf4j:slf4j-api:2.0.18=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.18=checkstyle +org.springframework.boot:spring-boot-autoconfigure:4.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-configuration-processor:4.0.8=annotationProcessor +org.springframework.boot:spring-boot-graphql-test:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-graphql:4.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-http-client:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-http-codec:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-http-converter:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-jackson:4.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-reactor:4.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-restclient:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-resttestclient:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-security:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-servlet:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-graphql-test:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-graphql:4.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-jackson-test:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-jackson:4.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-logging:4.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-security:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-test:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-web:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-webmvc-test:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-webmvc:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter:4.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-test-autoconfigure:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-test:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-tomcat:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-web-server:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-webmvc-test:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-webmvc:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-webtestclient:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot:4.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +org.springframework.graphql:spring-graphql-test:2.0.5=testCompileClasspath,testRuntimeClasspath +org.springframework.graphql:spring-graphql:2.0.5=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +org.springframework.security:spring-security-config:7.0.7=testCompileClasspath,testRuntimeClasspath +org.springframework.security:spring-security-core:7.0.7=testCompileClasspath,testRuntimeClasspath +org.springframework.security:spring-security-crypto:7.0.7=testCompileClasspath,testRuntimeClasspath +org.springframework.security:spring-security-web:7.0.7=testCompileClasspath,testRuntimeClasspath +org.springframework:spring-aop:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +org.springframework:spring-beans:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +org.springframework:spring-context:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +org.springframework:spring-core:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +org.springframework:spring-expression:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +org.springframework:spring-test:7.0.9=testCompileClasspath,testRuntimeClasspath +org.springframework:spring-web:7.0.9=testCompileClasspath,testRuntimeClasspath +org.springframework:spring-webflux:7.0.9=testCompileClasspath,testRuntimeClasspath +org.springframework:spring-webmvc:7.0.9=testCompileClasspath,testRuntimeClasspath org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath org.yaml:snakeyaml:2.5=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath -tools.jackson.core:jackson-core:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath -tools.jackson.core:jackson-databind:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath -tools.jackson:jackson-bom:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +tools.jackson.core:jackson-core:3.1.5=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +tools.jackson.core:jackson-databind:3.1.5=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath +tools.jackson:jackson-bom:3.1.5=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath empty= diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/rsocket/GraphQlRSocketHandlerFactory.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/rsocket/GraphQlRSocketHandlerFactory.java new file mode 100644 index 00000000..1fb842e1 --- /dev/null +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/rsocket/GraphQlRSocketHandlerFactory.java @@ -0,0 +1,84 @@ +package dev.caskeleton.adapter.inbound.graphql.advanced.rsocket; + +import dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap.GraphQlAdvancedCapability; +import dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap.GraphQlAdvancedCapabilityDisabledException; +import dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap.GraphQlAdvancedFeatureFlags; +import java.util.Objects; +import java.util.Set; + +/** + * Decides whether the experimental RSocket transport may exist, and on what terms. + * + *

Two gates rather than one, and the second is the reason this is a factory. RSocket is graded + * experimental here, so the capability flag alone is not enough: the deployment must also have + * given explicit experimental approval. A flag can be set by anyone editing configuration; the + * approval is a separate act, and separating them is what stops an experimental transport from + * being switched on the way a supported one would be. + * + *

The named-consumer list is the third condition, enforced by {@link GraphQlRSocketProperties} + * itself. An experimental transport with no named consumer is one nobody will notice breaking, + * which is the state it must not be allowed to reach. + */ +public final class GraphQlRSocketHandlerFactory { + + private final GraphQlAdvancedFeatureFlags flags; + private final GraphQlRSocketProperties properties; + private final GraphQlRSocketRoutePolicy routes; + + /** + * @param flags which capabilities this deployment named, and whether it approved experiments + * @param properties the transport settings, including the named consumers + * @param routes which RSocket routes reach GraphQL + */ + public GraphQlRSocketHandlerFactory( + GraphQlAdvancedFeatureFlags flags, + GraphQlRSocketProperties properties, + GraphQlRSocketRoutePolicy routes) { + this.flags = Objects.requireNonNull(flags, "flags"); + this.properties = Objects.requireNonNull(properties, "properties"); + this.routes = Objects.requireNonNull(routes, "routes"); + } + + /** Whether the transport may be installed at all. */ + public boolean enabled() { + return properties.enabled() && flags.isEnabled(GraphQlAdvancedCapability.RSOCKET); + } + + /** + * The settings a handler is installed with. + * + * @throws GraphQlAdvancedCapabilityDisabledException when the capability was not named, or was + * named without experimental approval + */ + public GraphQlRSocketHandlerSettings settings() { + if (!enabled()) { + throw new GraphQlAdvancedCapabilityDisabledException( + GraphQlAdvancedCapability.RSOCKET.name()); + } + return new GraphQlRSocketHandlerSettings( + routes, properties.allowedMetadataMimeTypes(), properties.namedConsumers()); + } + + /** + * What a handler is configured with. + * + * @param routes which RSocket routes reach GraphQL + * @param allowedMetadataMimeTypes the metadata encodings accepted + * @param namedConsumers who this transport exists for + */ + public record GraphQlRSocketHandlerSettings( + GraphQlRSocketRoutePolicy routes, + Set allowedMetadataMimeTypes, + Set namedConsumers) { + + public GraphQlRSocketHandlerSettings { + Objects.requireNonNull(routes, "routes"); + allowedMetadataMimeTypes = Set.copyOf(allowedMetadataMimeTypes); + namedConsumers = Set.copyOf(namedConsumers); + if (namedConsumers.isEmpty()) { + throw new IllegalArgumentException( + "an experimental transport with no named consumer is one nobody will notice breaking"); + } + } + } +} diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/springdata/GraphQlRepositoryAllowlist.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/springdata/GraphQlRepositoryAllowlist.java new file mode 100644 index 00000000..b9b3a45b --- /dev/null +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/springdata/GraphQlRepositoryAllowlist.java @@ -0,0 +1,116 @@ +package dev.caskeleton.adapter.inbound.graphql.advanced.springdata; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +/** + * The repository exposures this deployment has actually decided to publish. + * + *

Empty by default, and that is the capability's entire safety property. Spring's + * {@code @GraphQlRepository} auto-registers a data fetcher for every annotated repository it finds, + * so without an allowlist the set of exposed fields is whatever somebody annotated — which is a + * decision made in a persistence class, by whoever was working on persistence, and reviewed as a + * persistence change. + * + *

Each registration carries its own argument, pagination and projection policies rather than + * sharing global ones. A coordinate that may sort by one column is not the same as one that may + * sort by another, and a single shared policy would have to be the union. + */ +public final class GraphQlRepositoryAllowlist { + + private final Map registrations; + + private GraphQlRepositoryAllowlist(Map registrations) { + this.registrations = Map.copyOf(registrations); + } + + /** Nothing exposed. The default, and what a deployment that has not decided has. */ + public static GraphQlRepositoryAllowlist empty() { + return new GraphQlRepositoryAllowlist(Map.of()); + } + + /** A builder, because an allowlist is written once at startup and read on every schema build. */ + public static Builder builder() { + return new Builder(); + } + + /** Whether a repository is exposed at all, under any coordinate. */ + public boolean contains(String repositoryName) { + return registrations.keySet().stream().anyMatch(key -> key.startsWith(repositoryName + "@")); + } + + /** The registration for one exposure, empty when it was never registered. */ + public Optional find(GraphQlRepositoryExposure exposure) { + Objects.requireNonNull(exposure, "exposure"); + return Optional.ofNullable(registrations.get(key(exposure))); + } + + /** Every registered exposure, for the startup report. */ + public List exposures() { + return registrations.keySet().stream().sorted().toList(); + } + + /** How many exposures are registered. */ + public int size() { + return registrations.size(); + } + + private static String key(GraphQlRepositoryExposure exposure) { + return exposure.repositoryName() + "@" + exposure.schemaCoordinate(); + } + + /** + * What one exposure is permitted to do. + * + * @param exposure which repository at which coordinate + * @param arguments the filters and sorts it accepts + * @param pagination how it pages + * @param projection what it may return + */ + public record Registration( + GraphQlRepositoryExposure exposure, + GraphQlRepositoryArgumentPolicy arguments, + GraphQlRepositoryPaginationPolicy pagination, + GraphQlRepositoryProjectionPolicy projection) { + + public Registration { + Objects.requireNonNull(exposure, "exposure"); + Objects.requireNonNull(arguments, "arguments"); + Objects.requireNonNull(pagination, "pagination"); + Objects.requireNonNull(projection, "projection"); + } + } + + /** Collects registrations and refuses a duplicate coordinate. */ + public static final class Builder { + + private final Map registrations = new LinkedHashMap<>(); + + private Builder() {} + + /** Register one exposure with its three policies. */ + public Builder expose( + GraphQlRepositoryExposure exposure, + GraphQlRepositoryArgumentPolicy arguments, + GraphQlRepositoryPaginationPolicy pagination, + GraphQlRepositoryProjectionPolicy projection) { + Registration registration = new Registration(exposure, arguments, pagination, projection); + Registration previous = registrations.putIfAbsent(key(exposure), registration); + if (previous != null) { + throw new IllegalArgumentException( + "two registrations for " + + key(exposure) + + "; whichever was added last would silently decide the coordinate's limits"); + } + return this; + } + + /** Freeze it. */ + public GraphQlRepositoryAllowlist build() { + return new GraphQlRepositoryAllowlist(registrations); + } + } +} diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/springdata/GraphQlRepositoryArgumentPolicy.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/springdata/GraphQlRepositoryArgumentPolicy.java new file mode 100644 index 00000000..f56e818b --- /dev/null +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/springdata/GraphQlRepositoryArgumentPolicy.java @@ -0,0 +1,64 @@ +package dev.caskeleton.adapter.inbound.graphql.advanced.springdata; + +import java.util.Objects; +import java.util.Set; + +/** + * Which filter and sort arguments a coordinate accepts. + * + *

The other half of the persistence-leak problem, and the half that is easier to miss. A + * query-by-example or Querydsl fetcher derives its predicate from whatever arguments arrive, so an + * un-allowlisted argument set means the client writes the {@code WHERE} clause — including over + * columns the schema never published, because the binder resolves against the entity rather than + * against the GraphQL type. + * + *

Sort is allowlisted separately from filter. They read alike and behave differently under load: + * a filter on an unindexed column is one slow query, and a sort on one is a full sort of the table + * on every page. + */ +public final class GraphQlRepositoryArgumentPolicy { + + private final Set filterableFields; + private final Set sortableFields; + + /** + * @param filterableFields the arguments that may narrow the result + * @param sortableFields the fields that may order it + */ + public GraphQlRepositoryArgumentPolicy(Set filterableFields, Set sortableFields) { + this.filterableFields = + Set.copyOf(Objects.requireNonNull(filterableFields, "filterableFields")); + this.sortableFields = Set.copyOf(Objects.requireNonNull(sortableFields, "sortableFields")); + } + + /** A coordinate that accepts no filter and no sort. */ + public static GraphQlRepositoryArgumentPolicy none() { + return new GraphQlRepositoryArgumentPolicy(Set.of(), Set.of()); + } + + /** Whether a filter argument is permitted. */ + public boolean filterable(String field) { + return field != null && filterableFields.contains(field); + } + + /** Whether a sort field is permitted. */ + public boolean sortable(String field) { + return field != null && sortableFields.contains(field); + } + + /** + * The arguments a request used that this coordinate does not accept. + * + *

All of them, not the first: a caller fixing a query should not discover the rejections one + * round trip at a time. + */ + public java.util.List reject(Set filters, Set sorts) { + Objects.requireNonNull(filters, "filters"); + Objects.requireNonNull(sorts, "sorts"); + return java.util.stream.Stream.concat( + filters.stream().filter(field -> !filterable(field)).map(field -> "filter:" + field), + sorts.stream().filter(field -> !sortable(field)).map(field -> "sort:" + field)) + .sorted() + .toList(); + } +} diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/springdata/GraphQlRepositoryExposure.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/springdata/GraphQlRepositoryExposure.java new file mode 100644 index 00000000..cbeb9cf4 --- /dev/null +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/springdata/GraphQlRepositoryExposure.java @@ -0,0 +1,28 @@ +package dev.caskeleton.adapter.inbound.graphql.advanced.springdata; + +import java.util.Objects; + +/** + * One repository offered at one schema coordinate. + * + *

The pair, never the repository alone. Spring's {@code @GraphQlRepository} binds a repository + * to whatever coordinate its return type matches, so the same repository reached from {@code + * Query.orders} and from {@code Query.allOrders} is two exposures with two different audiences — + * and an allowlist keyed only on the repository would authorise both from one decision. + * + * @param repositoryName the repository bean's name + * @param schemaCoordinate the {@code Type.field} it answers + */ +public record GraphQlRepositoryExposure(String repositoryName, String schemaCoordinate) { + + public GraphQlRepositoryExposure { + Objects.requireNonNull(repositoryName, "repositoryName"); + Objects.requireNonNull(schemaCoordinate, "schemaCoordinate"); + if (repositoryName.isBlank() || schemaCoordinate.isBlank()) { + throw new IllegalArgumentException("an exposure names a repository and a coordinate"); + } + if (schemaCoordinate.indexOf('.') < 1) { + throw new IllegalArgumentException("a schema coordinate is Type.field: " + schemaCoordinate); + } + } +} diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/springdata/GraphQlRepositoryExposureRejectedException.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/springdata/GraphQlRepositoryExposureRejectedException.java new file mode 100644 index 00000000..40ed568c --- /dev/null +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/springdata/GraphQlRepositoryExposureRejectedException.java @@ -0,0 +1,27 @@ +package dev.caskeleton.adapter.inbound.graphql.advanced.springdata; + +import java.util.Objects; + +/** + * A repository was about to be exposed as a data fetcher without being registered for it. + * + *

Raised at startup rather than at query time. An unregistered exposure that only failed when + * somebody queried it would be a schema field that exists, appears in introspection, and errors — + * which is worse than one that was never published. + */ +public final class GraphQlRepositoryExposureRejectedException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + private final transient String repositoryName; + + public GraphQlRepositoryExposureRejectedException(String repositoryName, String reason) { + super("repository exposure refused for '" + repositoryName + "': " + reason); + this.repositoryName = Objects.requireNonNull(repositoryName, "repositoryName"); + } + + /** Which repository. */ + public String repositoryName() { + return repositoryName; + } +} diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/springdata/GraphQlRepositoryExposureValidator.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/springdata/GraphQlRepositoryExposureValidator.java new file mode 100644 index 00000000..fc7e17f2 --- /dev/null +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/springdata/GraphQlRepositoryExposureValidator.java @@ -0,0 +1,88 @@ +package dev.caskeleton.adapter.inbound.graphql.advanced.springdata; + +import java.util.List; +import java.util.Objects; +import java.util.Set; + +/** + * Refuses a repository exposure the deployment did not register. + * + *

Runs at schema build, not at query time. An unregistered exposure that only failed when + * somebody queried it would be a field that exists, appears in introspection and errors — which is + * worse than one that was never published, because a client has already written code against it. + * + *

This whole capability is a compatibility path and is documented as one. It exists so an + * existing Spring Data GraphQL application can be brought onto this platform without rewriting + * every fetcher on day one; it is not the mainstream way to serve a field here, and a coordinate + * that stays on it indefinitely is a coordinate whose application-service boundary was never + * written. + */ +public final class GraphQlRepositoryExposureValidator { + + private final GraphQlRepositoryAllowlist allowlist; + + public GraphQlRepositoryExposureValidator(GraphQlRepositoryAllowlist allowlist) { + this.allowlist = Objects.requireNonNull(allowlist, "allowlist"); + } + + /** + * Require an exposure to be registered. + * + * @throws GraphQlRepositoryExposureRejectedException when it is not + */ + public GraphQlRepositoryAllowlist.Registration verify(GraphQlRepositoryExposure exposure) { + Objects.requireNonNull(exposure, "exposure"); + return allowlist + .find(exposure) + .orElseThrow( + () -> + new GraphQlRepositoryExposureRejectedException( + exposure.repositoryName(), + "no registration for coordinate " + + exposure.schemaCoordinate() + + "; a repository is exposed by a deployment decision, not by an" + + " annotation on a persistence class")); + } + + /** + * Check one request against the coordinate's registered limits. + * + * @param exposure which repository at which coordinate + * @param filters the filter arguments the request used + * @param sorts the sort fields it used + * @param pageSize the page size it asked for + * @param returnTypeName the type the fetcher would return + * @throws GraphQlRepositoryExposureRejectedException naming every violation at once + */ + public void verifyRequest( + GraphQlRepositoryExposure exposure, + Set filters, + Set sorts, + int pageSize, + String returnTypeName) { + GraphQlRepositoryAllowlist.Registration registration = verify(exposure); + List refused = + new java.util.ArrayList<>(registration.arguments().reject(filters, sorts)); + if (!registration.pagination().permits(pageSize)) { + refused.add("page size " + pageSize + " exceeds " + registration.pagination().maxPageSize()); + } + if (!registration.projection().permits(returnTypeName)) { + // The one that keeps this from becoming a database export. Returning the entity publishes + // every column and makes a rename in the database a breaking change for every client. + refused.add( + "return type " + + returnTypeName + + " is not an approved projection; returning the entity publishes the persistence" + + " model as an API"); + } + if (!refused.isEmpty()) { + throw new GraphQlRepositoryExposureRejectedException( + exposure.repositoryName(), String.join("; ", refused)); + } + } + + /** Whether the deployment has exposed anything at all. */ + public boolean anyExposure() { + return allowlist.size() > 0; + } +} diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/springdata/GraphQlRepositoryPaginationPolicy.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/springdata/GraphQlRepositoryPaginationPolicy.java new file mode 100644 index 00000000..04df7a25 --- /dev/null +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/springdata/GraphQlRepositoryPaginationPolicy.java @@ -0,0 +1,35 @@ +package dev.caskeleton.adapter.inbound.graphql.advanced.springdata; + +/** + * How many rows one coordinate may return, and how they are addressed. + * + *

Explicit, never inherited. Spring Data's GraphQL integration defaults to offset pagination + * with a page size of twenty, and a default is exactly what nobody reviews: a field that quietly + * serves twenty rows is fine until the client asks for the offset at row four hundred thousand, at + * which point the database is sorting the whole table to skip it. + * + *

Keyset is the recommended shape and the policy says which one is in use, because the two have + * different failure modes and an operator reading a slow query needs to know which they are looking + * at. + * + * @param keyset whether the coordinate pages by cursor rather than by offset + * @param maxPageSize the largest page a caller may request + */ +public record GraphQlRepositoryPaginationPolicy(boolean keyset, int maxPageSize) { + + public GraphQlRepositoryPaginationPolicy { + if (maxPageSize < 1) { + throw new IllegalArgumentException("a page of nothing is not a page"); + } + if (maxPageSize > 200) { + throw new IllegalArgumentException( + "a page above 200 rows makes one query a bulk export; if that is the intent it belongs" + + " on an endpoint that was designed for it"); + } + } + + /** Whether a requested page size is permitted. */ + public boolean permits(int requested) { + return requested >= 1 && requested <= maxPageSize; + } +} diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/springdata/GraphQlRepositoryProjectionPolicy.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/springdata/GraphQlRepositoryProjectionPolicy.java new file mode 100644 index 00000000..f5c89328 --- /dev/null +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/springdata/GraphQlRepositoryProjectionPolicy.java @@ -0,0 +1,57 @@ +package dev.caskeleton.adapter.inbound.graphql.advanced.springdata; + +import java.util.Objects; +import java.util.Set; + +/** + * Which projection types a coordinate may return. + * + *

The rule that keeps this compatibility path from becoming a persistence-model export. Spring + * Data's integration will happily return the entity, and an entity returned from GraphQL is the + * database schema published as an API — every column, every relation the fetch plan happens to + * traverse, and a rename in the database becoming a breaking change for every client. + * + *

So the entity type itself is refused by name, and only an approved projection is allowed. + */ +public final class GraphQlRepositoryProjectionPolicy { + + private final Set approvedProjections; + private final Set forbiddenEntityTypes; + + /** + * @param approvedProjections the projection interfaces or records that may be returned + * @param forbiddenEntityTypes the persistence types that may never be + */ + public GraphQlRepositoryProjectionPolicy( + Set approvedProjections, Set forbiddenEntityTypes) { + this.approvedProjections = + Set.copyOf(Objects.requireNonNull(approvedProjections, "approvedProjections")); + this.forbiddenEntityTypes = + Set.copyOf(Objects.requireNonNull(forbiddenEntityTypes, "forbiddenEntityTypes")); + if (this.approvedProjections.isEmpty()) { + throw new IllegalArgumentException( + "a coordinate with no approved projection can only return the entity, which is the one" + + " thing this policy exists to prevent"); + } + Set both = + this.approvedProjections.stream() + .filter(this.forbiddenEntityTypes::contains) + .collect(java.util.stream.Collectors.toUnmodifiableSet()); + if (!both.isEmpty()) { + throw new IllegalArgumentException( + "a type cannot be both an approved projection and a forbidden entity: " + both); + } + } + + /** Whether a return type may be served. */ + public boolean permits(String returnTypeName) { + return returnTypeName != null + && !forbiddenEntityTypes.contains(returnTypeName) + && approvedProjections.contains(returnTypeName); + } + + /** The approved projections, for the startup report. */ + public Set approvedProjections() { + return approvedProjections; + } +} diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/sse/GraphQlSseHandlerFactory.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/sse/GraphQlSseHandlerFactory.java new file mode 100644 index 00000000..3ab7536e --- /dev/null +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/sse/GraphQlSseHandlerFactory.java @@ -0,0 +1,95 @@ +package dev.caskeleton.adapter.inbound.graphql.advanced.sse; + +import dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap.GraphQlAdvancedCapability; +import dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap.GraphQlAdvancedCapabilityDisabledException; +import dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap.GraphQlAdvancedFeatureFlags; +import java.time.Duration; +import java.util.Objects; + +/** + * Decides whether the SSE subscription transport may exist, and on what terms. + * + *

SSE here is the Distinct Connection variant of the GraphQL-over-HTTP specification: one POST + * per subscription, each holding its own connection for as long as the subscription lives. That is + * the property worth stating at the factory, because it is the one that surprises people — {@code + * graphql-transport-ws} multiplexes many subscriptions onto one socket and this does not, so a + * client with twenty subscriptions holds twenty connections and a browser's six-per-origin limit is + * reached at six. + * + *

{@link GraphQlSseConnectionPolicy#connectionsRequiredFor(int)} is the arithmetic; this is + * where the transport is refused if the capability was not named. + */ +public final class GraphQlSseHandlerFactory { + + private final GraphQlAdvancedFeatureFlags flags; + private final GraphQlSseProperties properties; + private final GraphQlSseConnectionPolicy policy; + + /** + * @param flags which capabilities this deployment named + * @param properties the connection bounds + * @param policy what the transport may carry + */ + public GraphQlSseHandlerFactory( + GraphQlAdvancedFeatureFlags flags, + GraphQlSseProperties properties, + GraphQlSseConnectionPolicy policy) { + this.flags = Objects.requireNonNull(flags, "flags"); + this.properties = Objects.requireNonNull(properties, "properties"); + this.policy = Objects.requireNonNull(policy, "policy"); + } + + /** Whether the transport may be installed at all. */ + public boolean enabled() { + return flags.isEnabled(GraphQlAdvancedCapability.SSE_SUBSCRIPTION); + } + + /** + * The settings a handler is installed with. + * + * @throws GraphQlAdvancedCapabilityDisabledException when the capability was not named + */ + public GraphQlSseHandlerSettings settings() { + if (!enabled()) { + throw new GraphQlAdvancedCapabilityDisabledException( + GraphQlAdvancedCapability.SSE_SUBSCRIPTION.name()); + } + return new GraphQlSseHandlerSettings( + properties.heartbeatInterval(), + properties.idleTimeout(), + properties.maximumDuration(), + policy); + } + + /** + * How many connections a client running this many subscriptions will hold. + * + *

Exposed on the factory so a deployment can answer the question before enabling the transport + * rather than after a support ticket about a browser that stopped loading images. + */ + public int connectionsRequiredFor(int subscriptions) { + return policy.connectionsRequiredFor(subscriptions); + } + + /** + * What a handler is configured with. + * + * @param heartbeatInterval how often a keepalive is written + * @param idleTimeout how long a silent connection is held + * @param maximumDuration the ceiling regardless of activity + * @param policy what the transport may carry + */ + public record GraphQlSseHandlerSettings( + Duration heartbeatInterval, + Duration idleTimeout, + Duration maximumDuration, + GraphQlSseConnectionPolicy policy) { + + public GraphQlSseHandlerSettings { + Objects.requireNonNull(heartbeatInterval, "heartbeatInterval"); + Objects.requireNonNull(idleTimeout, "idleTimeout"); + Objects.requireNonNull(maximumDuration, "maximumDuration"); + Objects.requireNonNull(policy, "policy"); + } + } +} diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/websocket/GraphQlWebSocketHandlerFactory.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/websocket/GraphQlWebSocketHandlerFactory.java new file mode 100644 index 00000000..f10c1b54 --- /dev/null +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/advanced/websocket/GraphQlWebSocketHandlerFactory.java @@ -0,0 +1,102 @@ +package dev.caskeleton.adapter.inbound.graphql.advanced.websocket; + +import dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap.GraphQlAdvancedCapability; +import dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap.GraphQlAdvancedCapabilityDisabledException; +import dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap.GraphQlAdvancedFeatureFlags; +import java.time.Duration; +import java.util.Objects; + +/** + * Decides whether the {@code graphql-transport-ws} handler may exist, and on what terms. + * + *

A factory rather than a bean definition because the decision has three inputs and only one of + * them is a flag: the capability must be enabled, the properties must describe a connection that + * can actually be bounded, and the admission policy must be present. A configuration class that + * checked only the flag would produce a handler with an unbounded connection lifetime whenever a + * deployment forgot the rest, and an unbounded WebSocket is a connection slot held by whoever opens + * it. + * + *

The factory produces the settings a handler needs rather than the handler itself. Spring + * GraphQL's own {@code GraphQlWebSocketHandler} is the handler; what this leaf owns is whether it + * runs and what limits it runs under, and building the Spring type here would put a framework + * dependency in a module whose whole job is the decision. + */ +public final class GraphQlWebSocketHandlerFactory { + + private final GraphQlAdvancedFeatureFlags flags; + private final GraphQlWebSocketProperties properties; + private final GraphQlWebSocketAdmission admission; + + /** + * @param flags which capabilities this deployment named + * @param properties the connection bounds + * @param admission who may open a connection + */ + public GraphQlWebSocketHandlerFactory( + GraphQlAdvancedFeatureFlags flags, + GraphQlWebSocketProperties properties, + GraphQlWebSocketAdmission admission) { + this.flags = Objects.requireNonNull(flags, "flags"); + this.properties = Objects.requireNonNull(properties, "properties"); + this.admission = Objects.requireNonNull(admission, "admission"); + } + + /** Whether the transport may be installed at all. */ + public boolean enabled() { + return flags.isEnabled(GraphQlAdvancedCapability.WEBSOCKET_SUBSCRIPTION); + } + + /** + * The settings a handler is installed with. + * + * @throws GraphQlAdvancedCapabilityDisabledException when the capability was not named + */ + public GraphQlWebSocketHandlerSettings settings() { + if (!enabled()) { + // Refused rather than returning a disabled handler. A handler that exists and rejects every + // frame is an endpoint that answers, which is how a client discovers the transport is + // "supported" and writes code against it. + throw new GraphQlAdvancedCapabilityDisabledException( + GraphQlAdvancedCapability.WEBSOCKET_SUBSCRIPTION.name()); + } + return new GraphQlWebSocketHandlerSettings( + properties.connectionInitTimeout(), + properties.idleTimeout(), + properties.maximumConnectionAge(), + properties.maximumSubscriptionsPerConnection(), + properties.heartbeatInterval(), + admission); + } + + /** + * What a handler is configured with. + * + * @param connectionInitTimeout how long an unacknowledged connection is held + * @param idleTimeout how long a silent connection is held + * @param maximumConnectionAge the ceiling regardless of activity + * @param maximumSubscriptionsPerConnection how many operations one socket may run + * @param heartbeatInterval how often a keepalive is written + * @param admission who may open a connection + */ + public record GraphQlWebSocketHandlerSettings( + Duration connectionInitTimeout, + Duration idleTimeout, + Duration maximumConnectionAge, + int maximumSubscriptionsPerConnection, + Duration heartbeatInterval, + GraphQlWebSocketAdmission admission) { + + public GraphQlWebSocketHandlerSettings { + Objects.requireNonNull(connectionInitTimeout, "connectionInitTimeout"); + Objects.requireNonNull(idleTimeout, "idleTimeout"); + Objects.requireNonNull(maximumConnectionAge, "maximumConnectionAge"); + Objects.requireNonNull(heartbeatInterval, "heartbeatInterval"); + Objects.requireNonNull(admission, "admission"); + if (heartbeatInterval.compareTo(idleTimeout) >= 0) { + throw new IllegalArgumentException( + "a heartbeat at or beyond the idle timeout makes the server time out its own healthy" + + " connections between beats"); + } + } + } +} diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/compat/GraphQlSchemaComparator.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/compat/GraphQlSchemaComparator.java index a2dd6715..177cd5f3 100644 --- a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/compat/GraphQlSchemaComparator.java +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/compat/GraphQlSchemaComparator.java @@ -67,8 +67,8 @@ public final class GraphQlSchemaComparator { // an extension contributed — and cannot see it disappear either. Every schema that composes // from several files is exactly this shape, which made the omission a breaking change the gate // reported as no change at all. - Map previousTypes = withExtensions(previous); - Map candidateTypes = withExtensions(candidate); + Map> previousTypes = withExtensions(previous); + Map> candidateTypes = withExtensions(candidate); compareTypePresence(previousTypes, candidateTypes, changes); compareTypeKinds(previousTypes, candidateTypes, changes); @@ -89,14 +89,16 @@ public final class GraphQlSchemaComparator { *

The merged form is what a client sees: the engine builds one type out of the base * declaration and every extension of it, and a field's origin is invisible on the wire. */ - private static Map withExtensions(TypeDefinitionRegistry registry) { - Map merged = new LinkedHashMap<>(); + private static Map> withExtensions(TypeDefinitionRegistry registry) { + Map> merged = new LinkedHashMap<>(); registry.types().forEach((name, type) -> merged.put(name, mergeExtensions(registry, type))); return merged; } - private static TypeDefinition mergeExtensions( - TypeDefinitionRegistry registry, TypeDefinition type) { + // graphql.language.Type stays raw here; see the note on typeNames. + @SuppressWarnings("rawtypes") + private static TypeDefinition mergeExtensions( + TypeDefinitionRegistry registry, TypeDefinition type) { String name = type.getName(); if (type instanceof ObjectTypeDefinition object) { @@ -187,8 +189,8 @@ public final class GraphQlSchemaComparator { } /** The merged definitions of one kind. */ - private static Map typesOf( - Map types, Class kind) { + private static > Map typesOf( + Map> types, Class kind) { Map selected = new LinkedHashMap<>(); types.forEach( (name, type) -> { @@ -200,8 +202,8 @@ public final class GraphQlSchemaComparator { } private static void compareTypePresence( - Map previous, - Map candidate, + Map> previous, + Map> candidate, List changes) { Set previousTypes = new TreeSet<>(previous.keySet()); @@ -223,12 +225,12 @@ public final class GraphQlSchemaComparator { * to another, or as nothing at all. */ private static void compareTypeKinds( - Map previousTypes, - Map candidateTypes, + Map> previousTypes, + Map> candidateTypes, List changes) { for (String name : new TreeSet<>(previousTypes.keySet())) { - TypeDefinition after = candidateTypes.get(name); + TypeDefinition after = candidateTypes.get(name); if (after == null) { continue; } @@ -246,13 +248,13 @@ public final class GraphQlSchemaComparator { * definition untouched. */ private static void compareAppliedDirectives( - Map previousTypes, - Map candidateTypes, + Map> previousTypes, + Map> candidateTypes, List changes) { for (String name : new TreeSet<>(previousTypes.keySet())) { - TypeDefinition before = previousTypes.get(name); - TypeDefinition after = candidateTypes.get(name); + TypeDefinition before = previousTypes.get(name); + TypeDefinition after = candidateTypes.get(name); if (after == null || !before.getClass().equals(after.getClass())) { continue; } @@ -327,8 +329,8 @@ public final class GraphQlSchemaComparator { } private static void compareOutputTypes( - Map previous, - Map candidate, + Map> previous, + Map> candidate, List changes) { Map> previousTypes = implementingTypes(previous); @@ -382,8 +384,10 @@ public final class GraphQlSchemaComparator { : GraphQlChangeKind.OUTPUT_FIELD_ADDED_NULLABLE))); } + // graphql.language.Type stays raw here; see the note on typeNames. + @SuppressWarnings("rawtypes") private static void compareOutputFieldType( - String coordinate, Type before, Type after, List changes) { + String coordinate, Type before, Type after, List changes) { if (sameType(before, after)) { return; @@ -474,8 +478,8 @@ public final class GraphQlSchemaComparator { } private static void compareInputTypes( - Map previous, - Map candidate, + Map> previous, + Map> candidate, List changes) { Map previousTypes = @@ -527,10 +531,12 @@ public final class GraphQlSchemaComparator { } } + // graphql.language.Type stays raw here; see the note on typeNames. + @SuppressWarnings("rawtypes") private static void compareInputValueType( String coordinate, - Type before, - Type after, + Type before, + Type after, GraphQlChangeKind strengthened, GraphQlChangeKind relaxed, GraphQlChangeKind changed, @@ -551,8 +557,8 @@ public final class GraphQlSchemaComparator { } private static void compareEnums( - Map previous, - Map candidate, + Map> previous, + Map> candidate, List changes) { Map previousTypes = typesOf(previous, EnumTypeDefinition.class); @@ -586,8 +592,8 @@ public final class GraphQlSchemaComparator { } private static void compareUnions( - Map previous, - Map candidate, + Map> previous, + Map> candidate, List changes) { Map previousTypes = typesOf(previous, UnionTypeDefinition.class); @@ -677,7 +683,7 @@ public final class GraphQlSchemaComparator { } private static Map> implementingTypes( - Map merged) { + Map> merged) { Map> types = new LinkedHashMap<>(); typesOf(merged, ObjectTypeDefinition.class).forEach(types::put); typesOf(merged, InterfaceTypeDefinition.class).forEach(types::put); @@ -720,6 +726,26 @@ public final class GraphQlSchemaComparator { .collect(Collectors.toCollection(LinkedHashSet::new)); } + /** + * The names of a type reference list, taken exactly as graphql-java hands it over. + * + *

Every {@code TypeDefinition} in this class is parameterized. {@code graphql.language.Type} + * is not, and cannot be: graphql-java declares {@code Type} — its own bound is + * raw — and its collections are raw on both sides. {@code getImplements()} and {@code + * getMemberTypes()} return {@code List}, which is not assignable to {@code List>} because a raw element type is not a subtype of {@code Type} in an argument + * position; and {@code UnionTypeDefinition.Builder.memberTypes} takes {@code List} back, so + * a parameterized local cannot be handed to it either. Reading and writing are both raw. + * + *

So the rawness is admitted at the six members that touch {@code Type} rather than hidden + * behind defensive copies at every boundary. A copy would imply this class needed one, and the + * suppression is deliberately per-member rather than on the class so that a raw type introduced + * anywhere else here still surfaces. + * + *

The build does not enable {@code -Xlint:rawtypes}, so javac is silent either way; this + * exists for the editor's compiler, which is not silent and should not be made silent globally. + */ + @SuppressWarnings("rawtypes") private static Set typeNames(List types) { return types.stream() .map(GraphQlSchemaComparator::print) @@ -730,15 +756,21 @@ public final class GraphQlSchemaComparator { return nonNull(definition.getType()) && definition.getDefaultValue() == null; } - private static boolean nonNull(Type type) { + // graphql.language.Type stays raw here; see the note on typeNames. + @SuppressWarnings("rawtypes") + private static boolean nonNull(Type type) { return type instanceof NonNullType; } - private static Type unwrap(Type type) { + // graphql.language.Type stays raw here; see the note on typeNames. + @SuppressWarnings("rawtypes") + private static Type unwrap(Type type) { return type instanceof NonNullType nonNullType ? nonNullType.getType() : type; } - private static boolean sameType(Type left, Type right) { + // graphql.language.Type stays raw here; see the note on typeNames. + @SuppressWarnings("rawtypes") + private static boolean sameType(Type left, Type right) { return print(left).equals(print(right)); } @@ -747,7 +779,7 @@ public final class GraphQlSchemaComparator { } /** Names every definition kind the comparator understands, for coverage reporting. */ - public static Set> comparedDefinitionKinds() { + public static Set>> comparedDefinitionKinds() { return Set.of( ObjectTypeDefinition.class, InterfaceTypeDefinition.class, diff --git a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/moduleboundary/GraphQlAdvancedModule.java b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/moduleboundary/GraphQlAdvancedModule.java index 9a13c129..434b330b 100644 --- a/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/moduleboundary/GraphQlAdvancedModule.java +++ b/src/adapter/inbound/graphql/src/main/java/dev/caskeleton/adapter/inbound/graphql/moduleboundary/GraphQlAdvancedModule.java @@ -88,6 +88,16 @@ public enum GraphQlAdvancedModule { "security"), /** The GraphQL over WebSocket protocol state machine. */ + /** + * The allowlisted Spring Data compatibility path. + * + *

Pure policy and no edge to persistence, which is the point: this module decides which + * repository exposures a deployment published, and it decides that without being able to name a + * repository, an entity or a Spring Data type. Everything it works with is a name the deployment + * registered. + */ + SPRING_DATA("advanced.springdata", "advanced.springdata"), + WEBSOCKET("advanced.websocket", "advanced.websocket", "advanced.bootstrap"); private final String id; diff --git a/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/advanced/TransportHandlerFactoryTest.java b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/advanced/TransportHandlerFactoryTest.java new file mode 100644 index 00000000..f4777257 --- /dev/null +++ b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/advanced/TransportHandlerFactoryTest.java @@ -0,0 +1,169 @@ +package dev.caskeleton.adapter.inbound.graphql.advanced; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap.GraphQlAdvancedCapability; +import dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap.GraphQlAdvancedCapabilityDisabledException; +import dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap.GraphQlAdvancedFeatureFlags; +import dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap.GraphQlAdvancedModuleGuard; +import dev.caskeleton.adapter.inbound.graphql.advanced.rsocket.GraphQlRSocketHandlerFactory; +import dev.caskeleton.adapter.inbound.graphql.advanced.rsocket.GraphQlRSocketProperties; +import dev.caskeleton.adapter.inbound.graphql.advanced.rsocket.GraphQlRSocketRoutePolicy; +import dev.caskeleton.adapter.inbound.graphql.advanced.sse.GraphQlSseConnectionPolicy; +import dev.caskeleton.adapter.inbound.graphql.advanced.sse.GraphQlSseHandlerFactory; +import dev.caskeleton.adapter.inbound.graphql.advanced.sse.GraphQlSseProperties; +import dev.caskeleton.adapter.inbound.graphql.advanced.websocket.GraphQlWebSocketAdmission; +import dev.caskeleton.adapter.inbound.graphql.advanced.websocket.GraphQlWebSocketHandlerFactory; +import dev.caskeleton.adapter.inbound.graphql.advanced.websocket.GraphQlWebSocketProperties; +import java.time.Clock; +import java.time.Duration; +import java.time.ZoneOffset; +import java.util.Set; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Whether each transport may exist at all, and what it is installed with. + * + *

The three factories are the seam between "a deployment named this capability" and "a handler + * is serving requests". The seam matters because the alternative — a handler that is always + * installed and rejects when disabled — is an endpoint that answers, and an endpoint that answers + * is one a client writes code against. + */ +class TransportHandlerFactoryTest { + + private static final Clock CLOCK = + Clock.fixed(java.time.Instant.parse("2026-08-25T10:00:00Z"), ZoneOffset.UTC); + + private static GraphQlWebSocketAdmission admission(GraphQlAdvancedFeatureFlags flags) { + return new GraphQlWebSocketAdmission( + new GraphQlAdvancedModuleGuard(flags), GraphQlWebSocketProperties.defaults(), CLOCK); + } + + private static GraphQlWebSocketHandlerFactory websocket(GraphQlAdvancedFeatureFlags flags) { + return new GraphQlWebSocketHandlerFactory( + flags, GraphQlWebSocketProperties.defaults(), admission(flags)); + } + + private static GraphQlSseHandlerFactory sse(GraphQlAdvancedFeatureFlags flags) { + return new GraphQlSseHandlerFactory( + flags, GraphQlSseProperties.defaults(), GraphQlSseConnectionPolicy.standard()); + } + + private static GraphQlRSocketHandlerFactory rsocket( + GraphQlAdvancedFeatureFlags flags, boolean transportEnabled) { + return new GraphQlRSocketHandlerFactory( + flags, + new GraphQlRSocketProperties( + transportEnabled, + Set.of("graphql"), + Set.of("message/x.rsocket.routing.v0"), + transportEnabled ? Set.of("internal-gateway") : Set.of()), + new GraphQlRSocketRoutePolicy(Set.of("graphql"))); + } + + @Test + @DisplayName("no transport exists until its capability is named") + void noTransportExistsUntilNamed() { + GraphQlAdvancedFeatureFlags off = GraphQlAdvancedFeatureFlags.disabled(); + + assertThat(websocket(off).enabled()).isFalse(); + assertThat(sse(off).enabled()).isFalse(); + assertThat(rsocket(off, false).enabled()).isFalse(); + } + + @Test + @DisplayName("asking a disabled transport for its settings is refused, not answered emptily") + void disabledTransportRefusesRatherThanAnswering() { + // A handler that exists and rejects every request is an endpoint that answers, which is how a + // client discovers the transport is "supported". + GraphQlAdvancedFeatureFlags off = GraphQlAdvancedFeatureFlags.disabled(); + + assertThatThrownBy(() -> websocket(off).settings()) + .isInstanceOf(GraphQlAdvancedCapabilityDisabledException.class); + assertThatThrownBy(() -> sse(off).settings()) + .isInstanceOf(GraphQlAdvancedCapabilityDisabledException.class); + assertThatThrownBy(() -> rsocket(off, false).settings()) + .isInstanceOf(GraphQlAdvancedCapabilityDisabledException.class); + } + + @Test + @DisplayName("a named WebSocket capability produces bounded settings") + void websocketSettingsAreBounded() { + GraphQlWebSocketHandlerFactory.GraphQlWebSocketHandlerSettings settings = + websocket( + GraphQlAdvancedFeatureFlags.enabling( + GraphQlAdvancedCapability.WEBSOCKET_SUBSCRIPTION)) + .settings(); + + assertThat(settings.connectionInitTimeout()).isPositive(); + assertThat(settings.maximumConnectionAge()).isPositive(); + assertThat(settings.maximumSubscriptionsPerConnection()).isPositive(); + assertThat(settings.heartbeatInterval()).isLessThan(settings.idleTimeout()); + } + + @Test + @DisplayName("a heartbeat at or beyond the idle timeout is refused") + void selfDefeatingHeartbeatIsRefused() { + // The server would time out its own healthy connections between beats. + assertThatThrownBy( + () -> + new GraphQlWebSocketHandlerFactory.GraphQlWebSocketHandlerSettings( + Duration.ofSeconds(10), + Duration.ofSeconds(30), + Duration.ofHours(1), + 10, + Duration.ofSeconds(30), + admission(GraphQlAdvancedFeatureFlags.disabled()))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("time out its own healthy connections"); + } + + @Test + @DisplayName("SSE says how many connections a client will hold before it is enabled") + void sseReportsItsConnectionCost() { + // Distinct Connection: one POST per subscription. A client with twenty subscriptions holds + // twenty connections, and a browser's six-per-origin limit is reached at six. + GraphQlSseHandlerFactory factory = + sse(GraphQlAdvancedFeatureFlags.enabling(GraphQlAdvancedCapability.SSE_SUBSCRIPTION)); + + assertThat(factory.connectionsRequiredFor(20)).isEqualTo(20); + assertThat(factory.settings().heartbeatInterval()).isLessThan(factory.settings().idleTimeout()); + } + + @Test + @DisplayName("RSocket needs the transport switch as well as the capability") + void rsocketNeedsBothGates() { + // Two acts rather than one. A flag is set by whoever edits configuration; enabling an + // experimental transport should not be reachable the same way a supported one is. + GraphQlAdvancedFeatureFlags named = + GraphQlAdvancedFeatureFlags.enabling(GraphQlAdvancedCapability.RSOCKET); + + assertThat(rsocket(named, false).enabled()).isFalse(); + assertThat(rsocket(named, true).enabled()).isTrue(); + } + + @Test + @DisplayName("an experimental transport with no named consumer is refused") + void unnamedConsumerIsRefused() { + // One nobody will notice breaking, which is the state it must not reach. + assertThatThrownBy( + () -> + new GraphQlRSocketHandlerFactory.GraphQlRSocketHandlerSettings( + new GraphQlRSocketRoutePolicy(Set.of("graphql")), Set.of(), Set.of())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("nobody will notice breaking"); + } + + @Test + @DisplayName("enabling one transport does not enable another") + void capabilitiesAreIndependent() { + GraphQlAdvancedFeatureFlags onlyWebSocket = + GraphQlAdvancedFeatureFlags.enabling(GraphQlAdvancedCapability.WEBSOCKET_SUBSCRIPTION); + + assertThat(websocket(onlyWebSocket).enabled()).isTrue(); + assertThat(sse(onlyWebSocket).enabled()).isFalse(); + assertThat(rsocket(onlyWebSocket, true).enabled()).isFalse(); + } +} diff --git a/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/advanced/springdata/GraphQlRepositoryExposureValidatorTest.java b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/advanced/springdata/GraphQlRepositoryExposureValidatorTest.java new file mode 100644 index 00000000..abd19d39 --- /dev/null +++ b/src/adapter/inbound/graphql/src/test/java/dev/caskeleton/adapter/inbound/graphql/advanced/springdata/GraphQlRepositoryExposureValidatorTest.java @@ -0,0 +1,214 @@ +package dev.caskeleton.adapter.inbound.graphql.advanced.springdata; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.Set; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * The allowlist that stops a persistence annotation from publishing an API. + * + *

Spring's {@code @GraphQlRepository} registers a data fetcher for every annotated repository it + * finds. Without this, the set of exposed fields is whatever somebody annotated — a decision made + * in a persistence class, by whoever was working on persistence, and reviewed as a persistence + * change. + */ +class GraphQlRepositoryExposureValidatorTest { + + private static final GraphQlRepositoryExposure ORDERS = + new GraphQlRepositoryExposure("OrderRepository", "Query.orders"); + + private static GraphQlRepositoryAllowlist allowlistWith( + GraphQlRepositoryArgumentPolicy arguments, + GraphQlRepositoryPaginationPolicy pagination, + GraphQlRepositoryProjectionPolicy projection) { + return GraphQlRepositoryAllowlist.builder() + .expose(ORDERS, arguments, pagination, projection) + .build(); + } + + private static GraphQlRepositoryProjectionPolicy projection() { + return new GraphQlRepositoryProjectionPolicy( + Set.of("OrderSummaryProjection"), Set.of("OrderEntity")); + } + + @Test + @DisplayName("an unregistered repository is refused") + void unregisteredRepositoryIsRejected() { + GraphQlRepositoryExposureValidator validator = + new GraphQlRepositoryExposureValidator(GraphQlRepositoryAllowlist.empty()); + + assertThatThrownBy(() -> validator.verify(ORDERS)) + .isInstanceOf(GraphQlRepositoryExposureRejectedException.class) + .hasMessageContaining("not by an annotation on a persistence class"); + } + + @Test + @DisplayName("the same repository at an unregistered coordinate is still refused") + void coordinateIsPartOfTheDecision() { + // Spring binds a repository to whatever coordinate its return type matches, so the same + // repository reached from two fields is two exposures with two audiences. + GraphQlRepositoryExposureValidator validator = + new GraphQlRepositoryExposureValidator( + allowlistWith( + GraphQlRepositoryArgumentPolicy.none(), + new GraphQlRepositoryPaginationPolicy(true, 50), + projection())); + + assertThat(validator.verify(ORDERS)).isNotNull(); + assertThatThrownBy( + () -> + validator.verify( + new GraphQlRepositoryExposure("OrderRepository", "Query.allOrders"))) + .isInstanceOf(GraphQlRepositoryExposureRejectedException.class); + } + + @Test + @DisplayName("returning the entity is refused, however it is reached") + void entityReturnIsRefused() { + // The rule that keeps this compatibility path from becoming a database export: every column + // published, and a rename in the database a breaking change for every client. + GraphQlRepositoryExposureValidator validator = + new GraphQlRepositoryExposureValidator( + allowlistWith( + GraphQlRepositoryArgumentPolicy.none(), + new GraphQlRepositoryPaginationPolicy(true, 50), + projection())); + + assertThatThrownBy(() -> validator.verifyRequest(ORDERS, Set.of(), Set.of(), 10, "OrderEntity")) + .isInstanceOf(GraphQlRepositoryExposureRejectedException.class) + .hasMessageContaining("publishes the persistence model as an API"); + validator.verifyRequest(ORDERS, Set.of(), Set.of(), 10, "OrderSummaryProjection"); + } + + @Test + @DisplayName("a filter the coordinate did not register is refused") + void unregisteredFilterIsRefused() { + // A query-by-example fetcher derives its predicate from whatever arrives, and the binder + // resolves against the entity — so an un-allowlisted argument filters on columns the schema + // never published. + GraphQlRepositoryExposureValidator validator = + new GraphQlRepositoryExposureValidator( + allowlistWith( + new GraphQlRepositoryArgumentPolicy(Set.of("status"), Set.of("placedAt")), + new GraphQlRepositoryPaginationPolicy(true, 50), + projection())); + + validator.verifyRequest( + ORDERS, Set.of("status"), Set.of("placedAt"), 10, "OrderSummaryProjection"); + assertThatThrownBy( + () -> + validator.verifyRequest( + ORDERS, Set.of("internalCostBasis"), Set.of(), 10, "OrderSummaryProjection")) + .isInstanceOf(GraphQlRepositoryExposureRejectedException.class) + .hasMessageContaining("filter:internalCostBasis"); + } + + @Test + @DisplayName("filter and sort are allowlisted separately") + void filterAndSortAreSeparate() { + // They read alike and behave differently under load: a filter on an unindexed column is one + // slow query, a sort on one is a full table sort on every page. + GraphQlRepositoryExposureValidator validator = + new GraphQlRepositoryExposureValidator( + allowlistWith( + new GraphQlRepositoryArgumentPolicy(Set.of("status"), Set.of()), + new GraphQlRepositoryPaginationPolicy(true, 50), + projection())); + + assertThatThrownBy( + () -> + validator.verifyRequest( + ORDERS, Set.of("status"), Set.of("status"), 10, "OrderSummaryProjection")) + .isInstanceOf(GraphQlRepositoryExposureRejectedException.class) + .hasMessageContaining("sort:status"); + } + + @Test + @DisplayName("every violation is reported at once") + void violationsAreReportedTogether() { + GraphQlRepositoryExposureValidator validator = + new GraphQlRepositoryExposureValidator( + allowlistWith( + GraphQlRepositoryArgumentPolicy.none(), + new GraphQlRepositoryPaginationPolicy(true, 20), + projection())); + + assertThatThrownBy( + () -> validator.verifyRequest(ORDERS, Set.of("a"), Set.of("b"), 500, "OrderEntity")) + .isInstanceOf(GraphQlRepositoryExposureRejectedException.class) + .hasMessageContaining("filter:a") + .hasMessageContaining("sort:b") + .hasMessageContaining("page size 500") + .hasMessageContaining("not an approved projection"); + } + + @Test + @DisplayName("a page size beyond the coordinate's limit is refused") + void oversizedPageIsRefused() { + GraphQlRepositoryPaginationPolicy policy = new GraphQlRepositoryPaginationPolicy(true, 50); + + assertThat(policy.permits(50)).isTrue(); + assertThat(policy.permits(51)).isFalse(); + assertThat(policy.permits(0)).isFalse(); + } + + @Test + @DisplayName("a page size that turns a query into a bulk export is refused at configuration") + void bulkExportPageSizeIsRefused() { + assertThatThrownBy(() -> new GraphQlRepositoryPaginationPolicy(true, 5_000)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("designed for it"); + } + + @Test + @DisplayName("a coordinate with no approved projection is refused at configuration") + void projectionlessCoordinateIsRefused() { + // It could only return the entity, which is the one thing the policy exists to prevent. + assertThatThrownBy(() -> new GraphQlRepositoryProjectionPolicy(Set.of(), Set.of("OrderEntity"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("this policy exists to prevent"); + } + + @Test + @DisplayName("two registrations for one coordinate are refused") + void duplicateRegistrationIsRefused() { + assertThatThrownBy( + () -> + GraphQlRepositoryAllowlist.builder() + .expose( + ORDERS, + GraphQlRepositoryArgumentPolicy.none(), + new GraphQlRepositoryPaginationPolicy(true, 20), + projection()) + .expose( + ORDERS, + GraphQlRepositoryArgumentPolicy.none(), + new GraphQlRepositoryPaginationPolicy(true, 200), + projection())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("silently decide the coordinate's limits"); + } + + @Test + @DisplayName("nothing is exposed until a deployment says so") + void nothingIsExposedByDefault() { + GraphQlRepositoryAllowlist empty = GraphQlRepositoryAllowlist.empty(); + + assertThat(empty.size()).isZero(); + assertThat(empty.contains("OrderRepository")).isFalse(); + assertThat(new GraphQlRepositoryExposureValidator(empty).anyExposure()).isFalse(); + } + + @Test + @DisplayName("an exposure names a repository and a Type.field coordinate") + void exposureShapeIsChecked() { + assertThatThrownBy(() -> new GraphQlRepositoryExposure("OrderRepository", "orders")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Type.field"); + assertThatThrownBy(() -> new GraphQlRepositoryExposure(" ", "Query.orders")) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/adapter/inbound/grpc/gradle.lockfile b/src/adapter/inbound/grpc/gradle.lockfile index 47129b6b..25d3aff7 100644 --- a/src/adapter/inbound/grpc/gradle.lockfile +++ b/src/adapter/inbound/grpc/gradle.lockfile @@ -2,14 +2,13 @@ # Manual edits can break the build and are not advised. # This file is expected to be part of source control. biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,testCompileClasspath -ch.qos.logback:logback-classic:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -ch.qos.logback:logback-core:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.fasterxml.jackson.core:jackson-annotations:2.20=testCompileClasspath,testRuntimeClasspath -com.fasterxml:classmate:1.7.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor +ch.qos.logback:logback-classic:1.5.38=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +ch.qos.logback:logback-core:1.5.38=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-annotations:2.21=testCompileClasspath,testRuntimeClasspath +com.fasterxml:classmate:1.7.3=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,testAnnotationProcessor com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs -com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,testCompileClasspath com.github.spotbugs:spotbugs:4.10.2=spotbugs com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs com.google.android:annotations:4.1.1.4=runtimeClasspath,testRuntimeClasspath @@ -40,13 +39,13 @@ com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnno com.google.protobuf:protobuf-java-util:3.25.5=runtimeClasspath,testRuntimeClasspath com.google.protobuf:protobuf-java:3.25.5=annotationProcessor,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins -com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath +com.jayway.jsonpath:json-path:2.10.0=testCompileClasspath,testRuntimeClasspath com.puppycrawl.tools:checkstyle:13.5.0=checkstyle com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath commons-beanutils:commons-beanutils:1.11.0=checkstyle commons-collections:commons-collections:3.2.2=checkstyle commons-io:commons-io:2.21.0=spotbugs -commons-logging:commons-logging:1.3.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +commons-logging:commons-logging:1.3.6=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath info.picocli:picocli:4.7.7=checkstyle io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor @@ -59,15 +58,15 @@ io.grpc:grpc-protobuf:1.68.1=runtimeClasspath,testCompileClasspath,testRuntimeCl io.grpc:grpc-services:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.grpc:grpc-stub:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.grpc:grpc-util:1.68.1=runtimeClasspath,testRuntimeClasspath -io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-commons:1.16.7=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-observation:1.16.7=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.perfmark:perfmark-api:0.27.0=runtimeClasspath,testRuntimeClasspath jakarta.activation:jakarta.activation-api:2.1.4=testCompileClasspath,testRuntimeClasspath jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath jakarta.validation:jakarta.validation-api:3.1.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=testCompileClasspath,testRuntimeClasspath +jakarta.xml.bind:jakarta.xml.bind-api:4.0.5=testCompileClasspath,testRuntimeClasspath javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor -jaxen:jaxen:2.0.0=spotbugs +jaxen:jaxen:2.0.6=spotbugs net.bytebuddy:byte-buddy-agent:1.17.8=testCompileClasspath,testRuntimeClasspath net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath net.minidev:accessors-smart:2.6.0=testCompileClasspath,testRuntimeClasspath @@ -80,19 +79,19 @@ org.apache.commons:commons-text:1.15.0=spotbugs org.apache.commons:commons-text:1.3=checkstyle org.apache.httpcomponents:httpclient:4.5.13=checkstyle org.apache.httpcomponents:httpcore:4.4.16=checkstyle -org.apache.logging.log4j:log4j-api:2.25.2=compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath -org.apache.logging.log4j:log4j-core:2.25.2=spotbugs -org.apache.logging.log4j:log4j-to-slf4j:2.25.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-api:2.25.5=compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-core:2.25.5=spotbugs +org.apache.logging.log4j:log4j-to-slf4j:2.25.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.apache.maven.doxia:doxia-core:1.12.0=checkstyle org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle -org.apache.tomcat.embed:tomcat-embed-core:11.0.14=testCompileClasspath,testRuntimeClasspath -org.apache.tomcat.embed:tomcat-embed-el:11.0.14=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-core:11.0.24=testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-el:11.0.24=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-websocket:11.0.24=testCompileClasspath,testRuntimeClasspath org.apache.xbean:xbean-reflect:3.7=checkstyle org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath -org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath +org.assertj:assertj-core:3.27.7=testCompileClasspath,testRuntimeClasspath org.awaitility:awaitility:4.3.0=testCompileClasspath,testRuntimeClasspath org.checkerframework:checker-qual:3.42.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.codehaus.mojo:animal-sniffer-annotations:1.24=runtimeClasspath,testRuntimeClasspath @@ -104,16 +103,16 @@ org.dom4j:dom4j:2.2.0=spotbugs org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath org.hibernate.validator:hibernate-validator:9.0.1.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.javassist:javassist:3.28.0-GA=checkstyle -org.jboss.logging:jboss-logging:3.6.1.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath -org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-engine:6.0.1=testRuntimeClasspath -org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath -org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath +org.jboss.logging:jboss-logging:3.6.3.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.jspecify:jspecify:1.0.1=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.3=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.3=testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.3=testRuntimeClasspath +org.junit:junit-bom:6.0.3=testCompileClasspath,testRuntimeClasspath org.junit:junit-bom:6.1.0=spotbugs org.mockito:mockito-core:5.20.0=mockitoAgent,testCompileClasspath,testRuntimeClasspath org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath @@ -132,47 +131,46 @@ org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor org.reflections:reflections:0.10.2=checkstyle org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath -org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath -org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j -org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-configuration-processor:4.0.0=annotationProcessor -org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-http-converter:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-restclient:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-servlet:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-validation:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-validation:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-web-server:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-aop:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-beans:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-context:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-core:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-expression:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-test:7.0.1=testCompileClasspath,testRuntimeClasspath -org.springframework:spring-web:7.0.1=testCompileClasspath,testRuntimeClasspath -org.springframework:spring-webmvc:7.0.1=testCompileClasspath,testRuntimeClasspath +org.slf4j:jul-to-slf4j:2.0.18=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-api:2.0.18=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.18=checkstyle +org.springframework.boot:spring-boot-autoconfigure:4.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-configuration-processor:4.0.8=annotationProcessor +org.springframework.boot:spring-boot-http-converter:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-jackson:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-resttestclient:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-servlet:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-jackson-test:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-jackson:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-logging:4.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-test:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-validation:4.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-webmvc-test:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-webmvc:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter:4.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-test-autoconfigure:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-test:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-tomcat:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-validation:4.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-web-server:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-webmvc-test:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-webmvc:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot:4.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-aop:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-beans:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-context:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-core:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-expression:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-test:7.0.9=testCompileClasspath,testRuntimeClasspath +org.springframework:spring-web:7.0.9=testCompileClasspath,testRuntimeClasspath +org.springframework:spring-webmvc:7.0.9=testCompileClasspath,testRuntimeClasspath org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath org.yaml:snakeyaml:2.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -tools.jackson.core:jackson-core:3.0.2=testCompileClasspath,testRuntimeClasspath -tools.jackson.core:jackson-databind:3.0.2=testCompileClasspath,testRuntimeClasspath -tools.jackson:jackson-bom:3.0.2=testCompileClasspath,testRuntimeClasspath +tools.jackson.core:jackson-core:3.1.5=testCompileClasspath,testRuntimeClasspath +tools.jackson.core:jackson-databind:3.1.5=testCompileClasspath,testRuntimeClasspath +tools.jackson:jackson-bom:3.1.5=testCompileClasspath,testRuntimeClasspath empty= diff --git a/src/adapter/inbound/web/build.gradle b/src/adapter/inbound/web/build.gradle index 2f991fa1..ee75f0c5 100644 --- a/src/adapter/inbound/web/build.gradle +++ b/src/adapter/inbound/web/build.gradle @@ -1,3 +1,9 @@ +// The inbound HTTP API execution platform design models itself as 23 Stable Gradle modules under +// modules/web. This repository's fail-closed module registry outranks that layout, so those modules +// are packages here and WebModuleBoundaryTest enforces the design's module dependency table. The +// full mapping, and the three other places the design's assumptions were adapted, are in +// docs/web/repository-adaptation.md. + // HTTP / web adapters. Depends on application and shared operational contracts. dependencies { implementation project(':application-core') @@ -16,6 +22,24 @@ dependencies { // never a hand-maintained stale schema). The release-blocking drift gate is // owned by feature-contract-verification-test-suite (planned). implementation 'org.springdoc:springdoc-openapi-starter-webmvc-api:3.0.0' + // The two Advanced representations, compile-only on purpose. They were `implementation` first, + // on the reasoning that a missing backend would surface as a NoClassDefFoundError at the first + // request that negotiated one. That reasoning was wrong about what the jars do: Spring Boot's + // Jackson auto-configuration registers an `xmlMapper` and a `cborMapper` the moment each + // backend is on the runtime classpath, and Spring registers a message converter for XML with + // it. So every deployment got three ObjectMapper beans — which broke `@Autowired ObjectMapper` + // in the composition root with an ambiguity — and, worse, silently began parsing + // `application/xml` request bodies. An Advanced capability that is off by default had turned + // XML deserialization on for everybody, which is the opposite of what the flag promises and an + // XXE surface nobody chose. + // + // Compile-only keeps the factories compiling and their tests running while leaving the runtime + // classpath to the deployment that enables the capability. `WebRepresentation.available()` is + // what turns the absent backend into a sentence instead of a NoClassDefFoundError. + compileOnly 'tools.jackson.dataformat:jackson-dataformat-cbor' + compileOnly 'tools.jackson.dataformat:jackson-dataformat-xml' + testImplementation 'tools.jackson.dataformat:jackson-dataformat-cbor' + testImplementation 'tools.jackson.dataformat:jackson-dataformat-xml' // Fileserver reactive transport. Only the WebFlux framework and Reactor core are declared — // deliberately not spring-boot-starter-webflux, which would put a second embedded server // (reactor-netty) on the runtime classpath. DispatcherServlet stays present, so Spring Boot's @@ -30,9 +54,178 @@ dependencies { tasks.named('test') { useJUnitPlatform { excludeTags 'security-boundary' + // The parity gate compares recordings written by three lanes. In `test` alone only one of + // them exists, and a gate that fails because the others have not run yet is a gate people + // learn to ignore. It runs from `webCrossStackParityTest`, which depends on all three. + excludeTags 'web-parity' } } +// The web platform's reusable ArchUnit rules ship in their own source set, consumed by this leaf's +// tests and by the composition root. A rule pack that only its own fixture tests import is verified +// as library code and applied to nothing — the shape the JPA testkit had to be corrected out of. +strictTestLanes { + sourceSet('testkit') { compilesAgainst 'main' } + // The Jetty compatibility lane is its own source set because it needs a different embedded + // server on the classpath. Two servers in one source set means Spring Boot picks one and the + // "Jetty" lane silently runs on Tomcat — a compatibility matrix that certifies the same + // container twice. + sourceSet('jettyCompatTest') { + compilesAgainst 'main', 'testkit' + inherits 'implementation' + } + // Reactor Netty is the Stable WebFlux server baseline and it cannot share a source set with + // Tomcat: Spring Boot deduces one application type from the classpath, so with both servers + // present the reactive gate would start a servlet container and certify nothing reactive. + // Nothing is inherited: the leaf's own `implementation` carries spring-boot-starter-web, and + // inheriting it would put Tomcat back on this lane's classpath. Boot then deduces a servlet + // application, starts a servlet container, and the reactive gate certifies the servlet stack. + // The Nginx lane runs the platform behind a real reverse proxy in a container. Its own source + // set because it is the only lane that needs Docker: folding it into `test` would make every + // developer's `check` depend on a container runtime, and the usual outcome of that is an + // @Disabled that nobody notices has been there for months. + sourceSet('nginxProxyTest') { + compilesAgainst 'main', 'testkit' + inherits 'implementation' + } + sourceSet('webfluxContractTest') { + compilesAgainst 'main', 'testkit' + // Inherits nothing. The default is to extend `testImplementation`, which extends the leaf's + // own `implementation` and therefore carries spring-boot-starter-web — and with Tomcat on + // the classpath Boot deduces a servlet application, starts a servlet container, and the + // reactive gate certifies the servlet stack while reporting itself green. + inherits() + } +} + +testkitPublisher { + consumedBy 'test' + publishAs 'webTestkit' +} + +// ArchUnit is declared after the testkit source set exists, because `testkitImplementation` is +// created by that declaration. +dependencies { + testkitImplementation 'com.tngtech.archunit:archunit-junit5:1.3.0' + testImplementation 'com.tngtech.archunit:archunit-junit5:1.3.0' + + // Jetty replaces Tomcat for this lane only. The exclusion is what makes the lane mean + // something: with both on the classpath Boot starts Tomcat and the lane certifies nothing. + jettyCompatTestImplementation('org.springframework.boot:spring-boot-starter-jetty') + jettyCompatTestImplementation('org.springframework.boot:spring-boot-starter-test') { + exclude group: 'org.springframework.boot', module: 'spring-boot-starter-tomcat' + } + jettyCompatTestImplementation('org.springframework.boot:spring-boot-starter-web') { + exclude group: 'org.springframework.boot', module: 'spring-boot-starter-tomcat' + } + jettyCompatTestImplementation 'org.springframework.boot:spring-boot-starter-security' + jettyCompatTestImplementation 'org.springframework.boot:spring-boot-starter-validation' + // The root build adds the launcher to `test` only; a custom lane has to say so itself, or the + // executor starts and finds no JUnit Platform. + jettyCompatTestRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + // The reactive lane: reactor-netty in, Tomcat out. The exclusion is what makes the lane mean + // something — Boot picks the servlet stack when both are present. + webfluxContractTestImplementation('org.springframework.boot:spring-boot-starter-webflux') { + exclude group: 'org.springframework.boot', module: 'spring-boot-starter-tomcat' + } + webfluxContractTestImplementation('org.springframework.boot:spring-boot-starter-test') { + exclude group: 'org.springframework.boot', module: 'spring-boot-starter-tomcat' + } + webfluxContractTestImplementation 'org.springframework.boot:spring-boot-starter-validation' + webfluxContractTestImplementation 'io.projectreactor:reactor-test' + // Explicit because this lane inherits nothing: the platform's auto-configuration references + // ObjectMapper, and without Jackson the condition evaluation fails before a server starts. + webfluxContractTestImplementation 'org.springframework.boot:spring-boot-starter-jackson' + webfluxContractTestImplementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310' + webfluxContractTestImplementation project(':application-core') + webfluxContractTestImplementation project(':shared-contract') + webfluxContractTestRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + // The Nginx lane. Testcontainers starts the proxy; the application runs in this JVM on a + // random port and the container reaches it through the host gateway. + nginxProxyTestImplementation 'org.springframework.boot:spring-boot-starter-test' + nginxProxyTestImplementation 'org.springframework.boot:spring-boot-starter-web' + nginxProxyTestImplementation 'org.springframework.boot:spring-boot-starter-security' + nginxProxyTestImplementation 'org.springframework.boot:spring-boot-starter-validation' + nginxProxyTestImplementation 'org.testcontainers:testcontainers' + nginxProxyTestImplementation 'org.testcontainers:testcontainers-junit-jupiter' + nginxProxyTestRuntimeOnly 'org.junit.platform:junit-platform-launcher' +} + +// The lane task. A release compatibility gate that is not wired to a task is a document. +tasks.register('webFluxContractTest', Test) { + group = 'verification' + description = 'Runs the Stable HTTP contract against a real Reactor Netty.' + testClassesDirs = sourceSets.webfluxContractTest.output.classesDirs + classpath = sourceSets.webfluxContractTest.runtimeClasspath + useJUnitPlatform() + failOnNoDiscoveredTests = true + outputs.upToDateWhen { false } + jvmArgs '-Duser.timezone=UTC' +} + +// Docker-gated, and it says so rather than skipping. A lane that quietly passes when the container +// runtime is missing is a lane that has been certifying nothing since whenever Docker last broke. +tasks.register('webNginxProxyTest', Test) { + group = 'verification' + description = 'Runs the proxy, prefix and spoofing contract behind a real Nginx.' + testClassesDirs = sourceSets.nginxProxyTest.output.classesDirs + classpath = sourceSets.nginxProxyTest.runtimeClasspath + useJUnitPlatform() + failOnNoDiscoveredTests = true + outputs.upToDateWhen { false } + jvmArgs '-Duser.timezone=UTC' +} + +// The cross-stack gate. It depends on every recording lane rather than tolerating a missing one: +// a parity check that compares whatever happens to be present would report agreement across a +// matrix with a hole in it. +tasks.register('webCrossStackParityTest', Test) { + group = 'verification' + description = 'Compares the wire contract recorded by Tomcat, Jetty and Reactor Netty.' + testClassesDirs = sourceSets.test.output.classesDirs + classpath = sourceSets.test.runtimeClasspath + useJUnitPlatform { + includeTags 'web-parity' + } + failOnNoDiscoveredTests = true + outputs.upToDateWhen { false } + jvmArgs '-Duser.timezone=UTC' + dependsOn 'test', 'webJettyCompatTest', 'webFluxContractTest' +} + +// The Advanced lane. Every capability is off unless a deployment names it, so none of them is +// exercised by anything a production deployment runs — which makes a lane that runs them all the +// only place a break is noticed before whoever enables it notices. +// +// They also run inside `test`, deliberately. They are ordinary unit tests, and excluding them from +// the PR gate to make this lane look meaningful would mean the PR gate stopped covering a fifth of +// the leaf. +tasks.register('webAdvancedTest', Test) { + group = 'verification' + description = 'Runs every web Advanced capability contract.' + testClassesDirs = sourceSets.test.output.classesDirs + classpath = sourceSets.test.runtimeClasspath + useJUnitPlatform { + includeTags 'web-advanced' + } + failOnNoDiscoveredTests = true + outputs.upToDateWhen { false } + jvmArgs '-Duser.timezone=UTC' +} + +tasks.register('webJettyCompatTest', Test) { + group = 'verification' + description = 'Runs the Stable HTTP contract against a real Jetty instead of Tomcat.' + testClassesDirs = sourceSets.jettyCompatTest.output.classesDirs + classpath = sourceSets.jettyCompatTest.runtimeClasspath + useJUnitPlatform() + failOnNoDiscoveredTests = true + outputs.upToDateWhen { false } + jvmArgs '-Duser.timezone=UTC' +} + strictTestLanes { lane('jpaPersistenceRedactionContractTest') { description = 'Runs the exact persistence error log/trace redaction contract used by JPA evidence.' diff --git a/src/adapter/inbound/web/gradle.lockfile b/src/adapter/inbound/web/gradle.lockfile index b8bb42d7..6e6bc297 100644 --- a/src/adapter/inbound/web/gradle.lockfile +++ b/src/adapter/inbound/web/gradle.lockfile @@ -1,188 +1,277 @@ # This is a Gradle generated file for dependency locking. # Manual edits can break the build and are not advised. # This file is expected to be part of source control. -biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,testCompileClasspath -ch.qos.logback:logback-classic:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -ch.qos.logback:logback-core:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.fasterxml.jackson.core:jackson-annotations:2.20=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.fasterxml.jackson.core:jackson-core:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.fasterxml.jackson.core:jackson-databind:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.fasterxml.jackson:jackson-bom:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.fasterxml:classmate:1.7.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor -com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor +biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,jettyCompatTestCompileClasspath,nginxProxyTestCompileClasspath,testCompileClasspath,testkitCompileClasspath,webfluxContractTestCompileClasspath +ch.qos.logback:logback-classic:1.5.38=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath +ch.qos.logback:logback-core:1.5.38=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath +com.fasterxml.jackson.core:jackson-annotations:2.21=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath +com.fasterxml.jackson.core:jackson-core:2.21.5=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath +com.fasterxml.jackson.core:jackson-databind:2.21.5=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath +com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.21.5=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.21.5=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath +com.fasterxml.jackson:jackson-bom:2.21.5=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath +com.fasterxml.woodstox:woodstox-core:7.1.1=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +com.fasterxml:classmate:1.7.3=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath +com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,jettyCompatTestAnnotationProcessor,nginxProxyTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor,webfluxContractTestAnnotationProcessor +com.github.docker-java:docker-java-api:3.7.1=nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath +com.github.docker-java:docker-java-transport-zerodep:3.7.1=nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath +com.github.docker-java:docker-java-transport:3.7.1=nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath +com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,jettyCompatTestAnnotationProcessor,nginxProxyTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor,webfluxContractTestAnnotationProcessor com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs -com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,testCompileClasspath com.github.spotbugs:spotbugs:4.10.2=spotbugs com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs -com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor -com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor -com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor -com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,spotbugs,testCompileClasspath +com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,jettyCompatTestAnnotationProcessor,nginxProxyTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor,webfluxContractTestAnnotationProcessor +com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,jettyCompatTestAnnotationProcessor,nginxProxyTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor,webfluxContractTestAnnotationProcessor +com.google.auto:auto-common:1.2.2=annotationProcessor,jettyCompatTestAnnotationProcessor,nginxProxyTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor,webfluxContractTestAnnotationProcessor +com.google.code.findbugs:jsr305:3.0.2=checkstyle,spotbugs com.google.code.gson:gson:2.13.2=spotbugs -com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor -com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,testCompileClasspath +com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,jettyCompatTestAnnotationProcessor,nginxProxyTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor,webfluxContractTestAnnotationProcessor +com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,jettyCompatTestCompileClasspath,nginxProxyTestCompileClasspath,testCompileClasspath,testkitCompileClasspath,webfluxContractTestCompileClasspath com.google.errorprone:error_prone_annotations:2.41.0=spotbugs com.google.errorprone:error_prone_annotations:2.47.0=checkstyle -com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor -com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor -com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor -com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor -com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor -com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,jettyCompatTestAnnotationProcessor,nginxProxyTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor,webfluxContractTestAnnotationProcessor +com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,jettyCompatTestAnnotationProcessor,nginxProxyTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor,webfluxContractTestAnnotationProcessor +com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,jettyCompatTestAnnotationProcessor,nginxProxyTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor,webfluxContractTestAnnotationProcessor +com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,jettyCompatTestAnnotationProcessor,nginxProxyTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor,webfluxContractTestAnnotationProcessor +com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,jettyCompatTestAnnotationProcessor,nginxProxyTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor,webfluxContractTestAnnotationProcessor +com.google.guava:guava:33.5.0-jre=annotationProcessor,jettyCompatTestAnnotationProcessor,nginxProxyTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor,webfluxContractTestAnnotationProcessor com.google.guava:guava:33.6.0-jre=checkstyle -com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,testAnnotationProcessor -com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor -com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor +com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,jettyCompatTestAnnotationProcessor,nginxProxyTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor,webfluxContractTestAnnotationProcessor +com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,jettyCompatTestAnnotationProcessor,nginxProxyTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor,webfluxContractTestAnnotationProcessor +com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,jettyCompatTestAnnotationProcessor,nginxProxyTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor,webfluxContractTestAnnotationProcessor com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins -com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath -com.nimbusds:nimbus-jose-jwt:10.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.jayway.jsonpath:json-path:2.10.0=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath +com.nimbusds:nimbus-jose-jwt:10.4=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath com.puppycrawl.tools:checkstyle:13.5.0=checkstyle -com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath +com.tngtech.archunit:archunit-junit5-api:1.3.0=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +com.tngtech.archunit:archunit-junit5-engine-api:1.3.0=jettyCompatTestRuntimeClasspath,nginxProxyTestRuntimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath +com.tngtech.archunit:archunit-junit5-engine:1.3.0=jettyCompatTestRuntimeClasspath,nginxProxyTestRuntimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath +com.tngtech.archunit:archunit-junit5:1.3.0=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +com.tngtech.archunit:archunit:1.3.0=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +com.vaadin.external.google:android-json:0.0.20131108.vaadin1=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath commons-beanutils:commons-beanutils:1.11.0=checkstyle +commons-codec:commons-codec:1.19.0=nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath commons-collections:commons-collections:3.2.2=checkstyle +commons-io:commons-io:2.20.0=nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath commons-io:commons-io:2.21.0=spotbugs -commons-logging:commons-logging:1.3.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +commons-logging:commons-logging:1.3.6=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath info.picocli:picocli:4.7.7=checkstyle -io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor -io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor -io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.projectreactor:reactor-core:3.8.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.projectreactor:reactor-test:3.8.0=testCompileClasspath,testRuntimeClasspath -io.swagger.core.v3:swagger-annotations-jakarta:2.2.38=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.swagger.core.v3:swagger-core-jakarta:2.2.38=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.swagger.core.v3:swagger-models-jakarta:2.2.38=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -jakarta.activation:jakarta.activation-api:2.1.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -jakarta.validation:jakarta.validation-api:3.1.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor -jaxen:jaxen:2.0.0=spotbugs -net.bytebuddy:byte-buddy-agent:1.17.8=testCompileClasspath,testRuntimeClasspath -net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath -net.minidev:accessors-smart:2.6.0=testCompileClasspath,testRuntimeClasspath -net.minidev:json-smart:2.6.0=testCompileClasspath,testRuntimeClasspath +io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,jettyCompatTestAnnotationProcessor,nginxProxyTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor,webfluxContractTestAnnotationProcessor +io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,jettyCompatTestAnnotationProcessor,nginxProxyTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor,webfluxContractTestAnnotationProcessor +io.micrometer:micrometer-commons:1.16.7=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath +io.micrometer:micrometer-observation:1.16.7=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath +io.netty:netty-buffer:4.2.17.Final=webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath +io.netty:netty-codec-base:4.2.17.Final=webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath +io.netty:netty-codec-classes-quic:4.2.17.Final=webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath +io.netty:netty-codec-compression:4.2.17.Final=webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath +io.netty:netty-codec-dns:4.2.17.Final=webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath +io.netty:netty-codec-http2:4.2.17.Final=webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath +io.netty:netty-codec-http3:4.2.17.Final=webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath +io.netty:netty-codec-http:4.2.17.Final=webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath +io.netty:netty-codec-native-quic:4.2.17.Final=webfluxContractTestRuntimeClasspath +io.netty:netty-codec-socks:4.2.17.Final=webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath +io.netty:netty-common:4.2.17.Final=webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath +io.netty:netty-handler-proxy:4.2.17.Final=webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath +io.netty:netty-handler:4.2.17.Final=webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath +io.netty:netty-resolver-dns-classes-macos:4.2.17.Final=webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath +io.netty:netty-resolver-dns-native-macos:4.2.17.Final=webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath +io.netty:netty-resolver-dns:4.2.17.Final=webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath +io.netty:netty-resolver:4.2.17.Final=webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath +io.netty:netty-transport-classes-epoll:4.2.17.Final=webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath +io.netty:netty-transport-native-epoll:4.2.17.Final=webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath +io.netty:netty-transport-native-unix-common:4.2.17.Final=webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath +io.netty:netty-transport:4.2.17.Final=webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath +io.projectreactor.netty:reactor-netty-core:1.3.7=webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath +io.projectreactor.netty:reactor-netty-http:1.3.7=webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath +io.projectreactor:reactor-core:3.8.7=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath +io.projectreactor:reactor-test:3.8.7=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath +io.swagger.core.v3:swagger-annotations-jakarta:2.2.38=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +io.swagger.core.v3:swagger-core-jakarta:2.2.38=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +io.swagger.core.v3:swagger-models-jakarta:2.2.38=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +jakarta.activation:jakarta.activation-api:2.1.4=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath +jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath +jakarta.enterprise:jakarta.enterprise.cdi-api:4.1.0=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath +jakarta.enterprise:jakarta.enterprise.lang-model:4.1.0=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath +jakarta.inject:jakarta.inject-api:2.0.1=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath +jakarta.interceptor:jakarta.interceptor-api:2.2.0=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath +jakarta.servlet:jakarta.servlet-api:6.1.0=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath +jakarta.transaction:jakarta.transaction-api:2.0.1=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath +jakarta.validation:jakarta.validation-api:3.1.1=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath +jakarta.websocket:jakarta.websocket-api:2.2.0=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath +jakarta.websocket:jakarta.websocket-client-api:2.2.0=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath +jakarta.xml.bind:jakarta.xml.bind-api:4.0.5=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath +javax.inject:javax.inject:1=annotationProcessor,jettyCompatTestAnnotationProcessor,nginxProxyTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor,webfluxContractTestAnnotationProcessor +jaxen:jaxen:2.0.6=spotbugs +net.bytebuddy:byte-buddy-agent:1.17.8=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath +net.bytebuddy:byte-buddy:1.17.8=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath +net.java.dev.jna:jna:5.18.1=nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath +net.minidev:accessors-smart:2.6.0=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath +net.minidev:json-smart:2.6.0=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs org.antlr:antlr4-runtime:4.13.2=checkstyle org.apache.bcel:bcel:6.12.0=spotbugs -org.apache.commons:commons-lang3:3.20.0=checkstyle,compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +org.apache.commons:commons-compress:1.28.0=nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath +org.apache.commons:commons-lang3:3.20.0=checkstyle,compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath org.apache.commons:commons-text:1.15.0=spotbugs org.apache.commons:commons-text:1.3=checkstyle org.apache.httpcomponents:httpclient:4.5.13=checkstyle org.apache.httpcomponents:httpcore:4.4.16=checkstyle -org.apache.logging.log4j:log4j-api:2.25.2=compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath -org.apache.logging.log4j:log4j-core:2.25.2=spotbugs -org.apache.logging.log4j:log4j-to-slf4j:2.25.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-api:2.25.5=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath +org.apache.logging.log4j:log4j-core:2.25.5=spotbugs +org.apache.logging.log4j:log4j-to-slf4j:2.25.5=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath org.apache.maven.doxia:doxia-core:1.12.0=checkstyle org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle -org.apache.tomcat.embed:tomcat-embed-core:11.0.14=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.apache.tomcat.embed:tomcat-embed-el:11.0.14=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-core:11.0.24=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-el:11.0.24=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-websocket:11.0.24=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath org.apache.xbean:xbean-reflect:3.7=checkstyle -org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath -org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath -org.awaitility:awaitility:4.3.0=testCompileClasspath,testRuntimeClasspath +org.apiguardian:apiguardian-api:1.1.2=jettyCompatTestCompileClasspath,nginxProxyTestCompileClasspath,testCompileClasspath,testkitCompileClasspath,webfluxContractTestCompileClasspath +org.assertj:assertj-core:3.27.7=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath +org.awaitility:awaitility:4.3.0=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle org.codehaus.plexus:plexus-utils:3.3.0=checkstyle +org.codehaus.woodstox:stax2-api:4.2.2=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath org.dom4j:dom4j:2.2.0=spotbugs -org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath -org.hibernate.validator:hibernate-validator:9.0.1.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.eclipse.jetty.compression:jetty-compression-common:12.1.12=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath +org.eclipse.jetty.compression:jetty-compression-gzip:12.1.12=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath +org.eclipse.jetty.compression:jetty-compression-server:12.1.12=jettyCompatTestRuntimeClasspath +org.eclipse.jetty.ee11.websocket:jetty-ee11-websocket-jakarta-client:12.1.12=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath +org.eclipse.jetty.ee11.websocket:jetty-ee11-websocket-jakarta-common:12.1.12=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath +org.eclipse.jetty.ee11.websocket:jetty-ee11-websocket-jakarta-server:12.1.12=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath +org.eclipse.jetty.ee11.websocket:jetty-ee11-websocket-jetty-server:12.1.12=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath +org.eclipse.jetty.ee11.websocket:jetty-ee11-websocket-servlet:12.1.12=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath +org.eclipse.jetty.ee11:jetty-ee11-annotations:12.1.12=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath +org.eclipse.jetty.ee11:jetty-ee11-plus:12.1.12=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath +org.eclipse.jetty.ee11:jetty-ee11-servlet:12.1.12=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath +org.eclipse.jetty.ee11:jetty-ee11-webapp:12.1.12=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath +org.eclipse.jetty.ee:jetty-ee-webapp:12.1.12=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath +org.eclipse.jetty.websocket:jetty-websocket-core-client:12.1.12=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath +org.eclipse.jetty.websocket:jetty-websocket-core-common:12.1.12=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath +org.eclipse.jetty.websocket:jetty-websocket-core-server:12.1.12=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath +org.eclipse.jetty.websocket:jetty-websocket-jetty-api:12.1.12=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath +org.eclipse.jetty.websocket:jetty-websocket-jetty-common:12.1.12=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath +org.eclipse.jetty.websocket:jetty-websocket-jetty-server:12.1.12=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath +org.eclipse.jetty:jetty-alpn-client:12.1.12=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath +org.eclipse.jetty:jetty-annotations:12.1.12=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath +org.eclipse.jetty:jetty-client:12.1.12=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath +org.eclipse.jetty:jetty-http:12.1.12=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath +org.eclipse.jetty:jetty-io:12.1.12=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath +org.eclipse.jetty:jetty-plus:12.1.12=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath +org.eclipse.jetty:jetty-security:12.1.12=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath +org.eclipse.jetty:jetty-server:12.1.12=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath +org.eclipse.jetty:jetty-session:12.1.12=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath +org.eclipse.jetty:jetty-util:12.1.12=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath +org.eclipse.jetty:jetty-xml:12.1.12=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath +org.hamcrest:hamcrest:3.0=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath +org.hibernate.validator:hibernate-validator:9.0.1.Final=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath org.javassist:javassist:3.28.0-GA=checkstyle -org.jboss.logging:jboss-logging:3.6.1.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath -org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-engine:6.0.1=testRuntimeClasspath -org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath -org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath +org.jboss.logging:jboss-logging:3.6.3.Final=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath +org.jetbrains:annotations:17.0.0=nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath +org.jspecify:jspecify:1.0.1=annotationProcessor,checkstyle,compileClasspath,jettyCompatTestAnnotationProcessor,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestAnnotationProcessor,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath,testkitAnnotationProcessor,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestAnnotationProcessor,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:6.0.3=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.3=jettyCompatTestRuntimeClasspath,nginxProxyTestRuntimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath,webfluxContractTestRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.3=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.3=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.3=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.3=jettyCompatTestRuntimeClasspath,nginxProxyTestRuntimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath,webfluxContractTestRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.3=jettyCompatTestRuntimeClasspath,nginxProxyTestRuntimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath,webfluxContractTestRuntimeClasspath +org.junit:junit-bom:6.0.3=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath org.junit:junit-bom:6.1.0=spotbugs -org.mockito:mockito-core:5.20.0=mockitoAgent,testCompileClasspath,testRuntimeClasspath -org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath -org.objenesis:objenesis:3.3=testRuntimeClasspath -org.openapitools:jackson-databind-nullable:0.2.6=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath -org.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,testCompileClasspath -org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,testCompileClasspath -org.osgi:org.osgi.resource:1.0.0=compileClasspath,testCompileClasspath -org.osgi:org.osgi.service.serviceloader:1.0.0=compileClasspath,testCompileClasspath +org.mockito:mockito-core:5.20.0=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,mockitoAgent,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath +org.mockito:mockito-junit-jupiter:5.20.0=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath +org.objenesis:objenesis:3.3=jettyCompatTestRuntimeClasspath,nginxProxyTestRuntimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath,webfluxContractTestRuntimeClasspath +org.openapitools:jackson-databind-nullable:0.2.6=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.opentest4j:opentest4j:1.3.0=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath +org.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,jettyCompatTestCompileClasspath,nginxProxyTestCompileClasspath,testCompileClasspath,testkitCompileClasspath,webfluxContractTestCompileClasspath +org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,jettyCompatTestCompileClasspath,nginxProxyTestCompileClasspath,testCompileClasspath,testkitCompileClasspath,webfluxContractTestCompileClasspath +org.osgi:org.osgi.resource:1.0.0=compileClasspath,jettyCompatTestCompileClasspath,nginxProxyTestCompileClasspath,testCompileClasspath,testkitCompileClasspath,webfluxContractTestCompileClasspath +org.osgi:org.osgi.service.serviceloader:1.0.0=compileClasspath,jettyCompatTestCompileClasspath,nginxProxyTestCompileClasspath,testCompileClasspath,testkitCompileClasspath,webfluxContractTestCompileClasspath org.ow2.asm:asm-analysis:9.10.1=spotbugs -org.ow2.asm:asm-commons:9.10.1=spotbugs -org.ow2.asm:asm-tree:9.10.1=spotbugs +org.ow2.asm:asm-commons:9.10.1=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,spotbugs +org.ow2.asm:asm-tree:9.10.1=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,spotbugs org.ow2.asm:asm-util:9.10.1=spotbugs -org.ow2.asm:asm:9.10.1=spotbugs -org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath -org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor -org.reactivestreams:reactive-streams:1.0.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.ow2.asm:asm:9.10.1=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,spotbugs +org.ow2.asm:asm:9.7.1=nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath +org.pcollections:pcollections:4.0.1=annotationProcessor,jettyCompatTestAnnotationProcessor,nginxProxyTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor,webfluxContractTestAnnotationProcessor +org.reactivestreams:reactive-streams:1.0.4=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath org.reflections:reflections:0.10.2=checkstyle -org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath -org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath -org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j -org.springdoc:springdoc-openapi-starter-common:3.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springdoc:springdoc-openapi-starter-webmvc-api:3.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-configuration-processor:4.0.0=annotationProcessor -org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-http-converter:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-jackson:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-restclient:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-security-oauth2-resource-server:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-security:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-servlet:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-jackson:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-oauth2-resource-server:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-security:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-tomcat:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-validation:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-web:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-tomcat:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-validation:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-web-server:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-webmvc:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.security:spring-security-config:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.security:spring-security-core:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.security:spring-security-crypto:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.security:spring-security-oauth2-core:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.security:spring-security-oauth2-jose:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.security:spring-security-oauth2-resource-server:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.security:spring-security-test:7.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.security:spring-security-web:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.session:spring-session-core:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-aop:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-beans:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-context:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-core:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-expression:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-test:7.0.1=testCompileClasspath,testRuntimeClasspath -org.springframework:spring-web:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-webflux:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-webmvc:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.rnorth.duct-tape:duct-tape:1.0.8=nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath +org.skyscreamer:jsonassert:1.5.3=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath +org.slf4j:jul-to-slf4j:2.0.18=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath +org.slf4j:slf4j-api:2.0.18=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath +org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.18=checkstyle +org.springdoc:springdoc-openapi-starter-common:3.0.0=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springdoc:springdoc-openapi-starter-webmvc-api:3.0.0=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-autoconfigure:4.0.8=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath +org.springframework.boot:spring-boot-configuration-processor:4.0.8=annotationProcessor +org.springframework.boot:spring-boot-http-codec:4.0.8=webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath +org.springframework.boot:spring-boot-http-converter:4.0.8=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-jackson:4.0.8=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath +org.springframework.boot:spring-boot-jetty:4.0.8=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath +org.springframework.boot:spring-boot-netty:4.0.8=webfluxContractTestRuntimeClasspath +org.springframework.boot:spring-boot-reactor-netty:4.0.8=webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath +org.springframework.boot:spring-boot-reactor:4.0.8=webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath +org.springframework.boot:spring-boot-resttestclient:4.0.8=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-security-oauth2-resource-server:4.0.8=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-security:4.0.8=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-servlet:4.0.8=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-starter-jackson-test:4.0.8=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-starter-jackson:4.0.8=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath +org.springframework.boot:spring-boot-starter-jetty-runtime:4.0.8=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath +org.springframework.boot:spring-boot-starter-jetty:4.0.8=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath +org.springframework.boot:spring-boot-starter-logging:4.0.8=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath +org.springframework.boot:spring-boot-starter-oauth2-resource-server:4.0.8=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-starter-reactor-netty:4.0.8=webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath +org.springframework.boot:spring-boot-starter-security:4.0.8=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-starter-test:4.0.8=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.8=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat:4.0.8=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-starter-validation:4.0.8=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath +org.springframework.boot:spring-boot-starter-web:4.0.8=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-starter-webflux:4.0.8=webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath +org.springframework.boot:spring-boot-starter-webmvc-test:4.0.8=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-starter-webmvc:4.0.8=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-starter:4.0.8=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath +org.springframework.boot:spring-boot-test-autoconfigure:4.0.8=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath +org.springframework.boot:spring-boot-test:4.0.8=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath +org.springframework.boot:spring-boot-tomcat:4.0.8=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-validation:4.0.8=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath +org.springframework.boot:spring-boot-web-server:4.0.8=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath +org.springframework.boot:spring-boot-webflux:4.0.8=webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath +org.springframework.boot:spring-boot-webmvc-test:4.0.8=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-webmvc:4.0.8=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot:4.0.8=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath +org.springframework.security:spring-security-config:7.0.7=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.security:spring-security-core:7.0.7=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.security:spring-security-crypto:7.0.7=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.security:spring-security-oauth2-core:7.0.7=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.security:spring-security-oauth2-jose:7.0.7=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.security:spring-security-oauth2-resource-server:7.0.7=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.security:spring-security-test:7.0.7=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.security:spring-security-web:7.0.7=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.session:spring-session-core:4.0.5=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework:spring-aop:7.0.9=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath +org.springframework:spring-beans:7.0.9=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath +org.springframework:spring-context:7.0.9=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath +org.springframework:spring-core:7.0.9=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath +org.springframework:spring-expression:7.0.9=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath +org.springframework:spring-test:7.0.9=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath +org.springframework:spring-web:7.0.9=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath +org.springframework:spring-webflux:7.0.9=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath +org.springframework:spring-webmvc:7.0.9=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.testcontainers:testcontainers-junit-jupiter:2.0.5=nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath +org.testcontainers:testcontainers:2.0.5=nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs -org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath -org.yaml:snakeyaml:2.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -tools.jackson.core:jackson-core:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -tools.jackson.core:jackson-databind:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -tools.jackson:jackson-bom:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.xmlunit:xmlunit-core:2.10.4=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath +org.yaml:snakeyaml:2.5=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath +tools.jackson.core:jackson-core:3.1.5=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath +tools.jackson.core:jackson-databind:3.1.5=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath +tools.jackson.dataformat:jackson-dataformat-cbor:3.1.5=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +tools.jackson.dataformat:jackson-dataformat-xml:3.1.5=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +tools.jackson:jackson-bom:3.1.5=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath empty= diff --git a/src/adapter/inbound/web/src/jettyCompatTest/java/dev/caskeleton/adapter/inbound/web/testkit/budget/JettyWebBudgetIT.java b/src/adapter/inbound/web/src/jettyCompatTest/java/dev/caskeleton/adapter/inbound/web/testkit/budget/JettyWebBudgetIT.java new file mode 100644 index 00000000..cac69e5a --- /dev/null +++ b/src/adapter/inbound/web/src/jettyCompatTest/java/dev/caskeleton/adapter/inbound/web/testkit/budget/JettyWebBudgetIT.java @@ -0,0 +1,30 @@ +package dev.caskeleton.adapter.inbound.web.testkit.budget; + +import dev.caskeleton.webtestkit.BudgetFixtureApplication; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.test.context.ActiveProfiles; + +/** + * Budget enforcement on Jetty. + * + *

Containers impose bounds of their own — on the request line, on header size, on how a body is + * fed to a filter — and they differ. Running the same contract on the second one is what tells us + * whether a refusal came from the platform or from Tomcat. + */ +@SpringBootTest( + classes = BudgetFixtureApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + // The budget handler is gated on this property. Turning it on here rather than defaulting it + // on keeps the production default off: a control that is on by default is one nobody notices. + properties = "backend.web.budgets.enabled=true") +@ActiveProfiles("web-contract") +class JettyWebBudgetIT extends WebBudgetContract { + + @LocalServerPort private int port; + + @Override + protected HttpBudgetFixture fixture() { + return new HttpBudgetFixture(port); + } +} diff --git a/src/adapter/inbound/web/src/jettyCompatTest/java/dev/caskeleton/adapter/inbound/web/testkit/contract/JettyContractRecordingIT.java b/src/adapter/inbound/web/src/jettyCompatTest/java/dev/caskeleton/adapter/inbound/web/testkit/contract/JettyContractRecordingIT.java new file mode 100644 index 00000000..366f2581 --- /dev/null +++ b/src/adapter/inbound/web/src/jettyCompatTest/java/dev/caskeleton/adapter/inbound/web/testkit/contract/JettyContractRecordingIT.java @@ -0,0 +1,26 @@ +package dev.caskeleton.adapter.inbound.web.testkit.contract; + +import dev.caskeleton.webtestkit.ContractFixtureApplication; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.test.context.ActiveProfiles; + +/** Records the wire contract as the second servlet container serves it. */ +@SpringBootTest( + classes = ContractFixtureApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("web-contract") +class JettyContractRecordingIT extends WebPlatformContractRecording { + + @LocalServerPort private int port; + + @Override + protected WebContractFixture fixture() { + return new WebContractFixture(port); + } + + @Override + protected String laneName() { + return "jetty"; + } +} diff --git a/src/adapter/inbound/web/src/jettyCompatTest/java/dev/caskeleton/adapter/inbound/web/testkit/fault/JettyCommitThenConnectionResetIT.java b/src/adapter/inbound/web/src/jettyCompatTest/java/dev/caskeleton/adapter/inbound/web/testkit/fault/JettyCommitThenConnectionResetIT.java new file mode 100644 index 00000000..a2361a1e --- /dev/null +++ b/src/adapter/inbound/web/src/jettyCompatTest/java/dev/caskeleton/adapter/inbound/web/testkit/fault/JettyCommitThenConnectionResetIT.java @@ -0,0 +1,28 @@ +package dev.caskeleton.adapter.inbound.web.testkit.fault; + +import dev.caskeleton.webtestkit.ContractFixtureApplication; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.test.context.ActiveProfiles; + +/** + * The response-loss contract on Jetty. + * + *

Run on the second container because losing a response is exactly where containers differ: they + * detect a departed client at different moments and unwind the request differently. The contract + * they must both keep — the write happened once, the retry recovers it — is the same, so it is + * asserted from the same shared class rather than restated here. + */ +@SpringBootTest( + classes = ContractFixtureApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("web-contract") +class JettyCommitThenConnectionResetIT extends IdempotencyResponseLossContract { + + @LocalServerPort private int port; + + @Override + protected ResponseLossFixture fixture() { + return new HttpResponseLossFixture(port); + } +} diff --git a/src/adapter/inbound/web/src/jettyCompatTest/java/dev/caskeleton/adapter/inbound/web/testkit/mvc/JettyWebContractIT.java b/src/adapter/inbound/web/src/jettyCompatTest/java/dev/caskeleton/adapter/inbound/web/testkit/mvc/JettyWebContractIT.java new file mode 100644 index 00000000..65bc9558 --- /dev/null +++ b/src/adapter/inbound/web/src/jettyCompatTest/java/dev/caskeleton/adapter/inbound/web/testkit/mvc/JettyWebContractIT.java @@ -0,0 +1,48 @@ +package dev.caskeleton.adapter.inbound.web.testkit.mvc; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.webtestkit.ContractFixtureApplication; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.boot.web.server.servlet.context.ServletWebServerApplicationContext; +import org.springframework.test.context.ActiveProfiles; + +/** + * The same Stable HTTP contract, on Jetty. + * + *

The same one: {@link WebContractAssertions} is shared with the Tomcat lane rather than copied, + * because the design's rule is that container-specific behaviour is never imported back into the + * common core, and two copies is how the second one quietly becomes a different contract. + * + *

The first assertion checks which server actually started. Without it a classpath change that + * put Tomcat back would leave this lane green while certifying the same container twice — the + * failure mode a compatibility matrix exists to prevent and the one it is worst at detecting. + */ +@SpringBootTest( + classes = ContractFixtureApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("web-contract") +class JettyWebContractIT { + + @LocalServerPort private int port; + + @Autowired private ServletWebServerApplicationContext context; + + @Test + @DisplayName("the lane is actually running on Jetty") + void theLaneIsActuallyRunningOnJetty() { + assertThat(context.getWebServer().getClass().getName()) + .as("with Tomcat still on the classpath this lane would certify the same container twice") + .contains("Jetty"); + } + + @Test + @DisplayName("the whole Stable HTTP contract holds on Jetty") + void theWholeStableContractHoldsOnJetty() throws Exception { + new WebContractAssertions(port).assertWholeContract(); + } +} diff --git a/src/adapter/inbound/web/src/jettyCompatTest/java/dev/caskeleton/adapter/inbound/web/testkit/operation/JettyOperationHttpIT.java b/src/adapter/inbound/web/src/jettyCompatTest/java/dev/caskeleton/adapter/inbound/web/testkit/operation/JettyOperationHttpIT.java new file mode 100644 index 00000000..c6df351c --- /dev/null +++ b/src/adapter/inbound/web/src/jettyCompatTest/java/dev/caskeleton/adapter/inbound/web/testkit/operation/JettyOperationHttpIT.java @@ -0,0 +1,27 @@ +package dev.caskeleton.adapter.inbound.web.testkit.operation; + +import dev.caskeleton.webtestkit.ContractFixtureApplication; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.test.context.ActiveProfiles; + +/** + * The operation resource contract on Jetty. + * + *

Containers differ in how they normalise a {@code Location} and whether they keep a {@code + * Content-Location} on a 200. Those are exactly the headers this contract turns on, so running it + * on the second servlet container is not redundancy. + */ +@SpringBootTest( + classes = ContractFixtureApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("web-contract") +class JettyOperationHttpIT extends OperationHttpContract { + + @LocalServerPort private int port; + + @Override + protected HttpOperationFixture fixture() { + return new HttpOperationFixture(port); + } +} diff --git a/src/adapter/inbound/web/src/jettyCompatTest/java/dev/caskeleton/adapter/inbound/web/testkit/order/JettyPipelineOrderIT.java b/src/adapter/inbound/web/src/jettyCompatTest/java/dev/caskeleton/adapter/inbound/web/testkit/order/JettyPipelineOrderIT.java new file mode 100644 index 00000000..75b9b6df --- /dev/null +++ b/src/adapter/inbound/web/src/jettyCompatTest/java/dev/caskeleton/adapter/inbound/web/testkit/order/JettyPipelineOrderIT.java @@ -0,0 +1,28 @@ +package dev.caskeleton.adapter.inbound.web.testkit.order; + +import dev.caskeleton.webtestkit.PipelineOrderFixtureApplication; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.test.context.ActiveProfiles; + +/** + * Pipeline order on Jetty. + * + *

Async redispatch is a servlet-container mechanism, and the two containers implement it + * separately — including when {@code isAsyncStarted} becomes true relative to the filter chain + * unwinding. The duplicate-observation guard turns on exactly that timing, so certifying it on one + * container says nothing about the other. + */ +@SpringBootTest( + classes = PipelineOrderFixtureApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("web-contract") +class JettyPipelineOrderIT extends WebPipelineOrderContract { + + @LocalServerPort private int port; + + @Override + protected HttpPipelineFixture fixture() { + return new HttpPipelineFixture(port); + } +} diff --git a/src/adapter/inbound/web/src/jettyCompatTest/java/dev/caskeleton/adapter/inbound/web/testkit/performance/JettyLoadAndShutdownIT.java b/src/adapter/inbound/web/src/jettyCompatTest/java/dev/caskeleton/adapter/inbound/web/testkit/performance/JettyLoadAndShutdownIT.java new file mode 100644 index 00000000..5e310429 --- /dev/null +++ b/src/adapter/inbound/web/src/jettyCompatTest/java/dev/caskeleton/adapter/inbound/web/testkit/performance/JettyLoadAndShutdownIT.java @@ -0,0 +1,63 @@ +package dev.caskeleton.adapter.inbound.web.testkit.performance; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.webtestkit.ContractFixtureApplication; +import java.time.Duration; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.boot.web.server.servlet.context.ServletWebServerApplicationContext; +import org.springframework.test.context.ActiveProfiles; + +/** + * Load, abuse and graceful shutdown on the the second servlet container. + * + *

Shutdown is asserted per container rather than once, because it is implemented per container. + * "Stop accepting, finish what is in flight" is a promise each server keeps in its own way, and the + * failure — a request cut off mid-response during a rolling deploy — looks to the client exactly + * like the network. + */ +@SpringBootTest( + classes = ContractFixtureApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = "server.shutdown=graceful") +@ActiveProfiles("web-contract") +class JettyLoadAndShutdownIT extends WebLoadAndShutdownContract { + + @LocalServerPort private int port; + + @Autowired private ServletWebServerApplicationContext context; + + @Override + protected WebLoadFixture fixture() { + return new WebLoadFixture(port); + } + + @Override + protected org.springframework.boot.web.server.WebServer webServer() { + return context.getWebServer(); + } + + @Test + @Tag("web-shutdown") + @DisplayName("graceful shutdown drains within the deployment's grace period") + void gracefulShutdownFinishesInFlightWork() { + // Last, and destructive: the context serves nothing afterwards. + WebLoadFixture fixture = fixture(); + assertThat(fixture.stillServing(loadPath())).isTrue(); + + GracefulShutdownProbe.Outcome outcome = + GracefulShutdownProbe.shutDown(webServer(), Duration.ofSeconds(10)); + + // Bounded on purpose. A shutdown that waits indefinitely for a connection to go idle is how a + // rolling deploy stalls with half the fleet drained — and it produces no error to alert on. + assertThat(outcome.took()).isLessThan(Duration.ofSeconds(15)); + assertThat(fixture.stillServing(loadPath())) + .as("the server accepted a new request after it was told to stop") + .isFalse(); + } +} diff --git a/src/adapter/inbound/web/src/jettyCompatTest/java/dev/caskeleton/adapter/inbound/web/testkit/throttle/JettyWebThrottleIT.java b/src/adapter/inbound/web/src/jettyCompatTest/java/dev/caskeleton/adapter/inbound/web/testkit/throttle/JettyWebThrottleIT.java new file mode 100644 index 00000000..a635453a --- /dev/null +++ b/src/adapter/inbound/web/src/jettyCompatTest/java/dev/caskeleton/adapter/inbound/web/testkit/throttle/JettyWebThrottleIT.java @@ -0,0 +1,41 @@ +package dev.caskeleton.adapter.inbound.web.testkit.throttle; + +import dev.caskeleton.webtestkit.ThrottleFixtureApplication; +import org.junit.jupiter.api.AfterEach; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.test.context.ActiveProfiles; + +/** + * Quota and capacity refusals on the second servlet container. + * + *

Real, because the capacity case needs one request to genuinely occupy a slot while another + * arrives. A mock dispatcher runs them one after the other, so the second never meets a full + * service and the 503 case would pass without ever having been exercised. + */ +@SpringBootTest( + classes = ThrottleFixtureApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("web-contract") +class JettyWebThrottleIT extends WebThrottleHttpContract { + + @LocalServerPort private int port; + + private HttpThrottleFixture fixture; + + @Override + protected HttpThrottleFixture fixture() { + if (fixture == null) { + fixture = new HttpThrottleFixture(port); + } + return fixture; + } + + @AfterEach + void closeFixture() { + if (fixture != null) { + fixture.close(); + fixture = null; + } + } +} diff --git a/src/adapter/inbound/web/src/jettyCompatTest/resources/application-web-contract.yaml b/src/adapter/inbound/web/src/jettyCompatTest/resources/application-web-contract.yaml new file mode 100644 index 00000000..e5fcde1a --- /dev/null +++ b/src/adapter/inbound/web/src/jettyCompatTest/resources/application-web-contract.yaml @@ -0,0 +1,19 @@ +# The profile the real-container contract gate runs under. +# +# Everything is switched off except the servlet transport itself. The gate is about the status +# contract on the wire, and a security chain or a file-server profile joining the context would make +# a failure here ambiguous between "the contract broke" and "an unrelated capability did". +spring: + main: + banner-mode: "off" + mvc: + problemdetails: + enabled: true +server: + error: + include-stacktrace: never + include-message: never +backend: + web: + mvc: + enabled: true diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/admin/platform/WebPlatformSnapshot.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/admin/platform/WebPlatformSnapshot.java new file mode 100644 index 00000000..810d8a12 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/admin/platform/WebPlatformSnapshot.java @@ -0,0 +1,50 @@ +package dev.caskeleton.adapter.inbound.web.admin.platform; + +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * What the platform is actually configured to do, as an operator can read it. + * + *

Read from the running objects, never from the configuration that was supposed to produce them. + * Every incident where a control was "enabled" and did nothing comes down to the same gap: the + * property was set, the bean was not created, and the only thing anyone could inspect was the + * property. A snapshot built from the beans reports the second fact. + * + * @param transport which stack is serving, "servlet" or "reactive" + * @param apiVersions the API major versions being served + * @param problemCodes every failure code this deployment can publish + * @param budgetProfiles the registered request budgets, by name + * @param admissionProfiles the registered admission profiles, by name + * @param cacheProfiles the registered cache profiles, by name + * @param installedControls which platform controls are actually wired, by name + */ +public record WebPlatformSnapshot( + String transport, + List apiVersions, + List problemCodes, + Map budgetProfiles, + Map admissionProfiles, + List cacheProfiles, + Map installedControls) { + + public WebPlatformSnapshot { + Objects.requireNonNull(transport, "transport"); + apiVersions = List.copyOf(apiVersions); + problemCodes = List.copyOf(problemCodes); + budgetProfiles = Map.copyOf(budgetProfiles); + admissionProfiles = Map.copyOf(admissionProfiles); + cacheProfiles = List.copyOf(cacheProfiles); + installedControls = Map.copyOf(installedControls); + } + + /** The controls that are declared but not wired. */ + public List uninstalledControls() { + return installedControls.entrySet().stream() + .filter(entry -> !entry.getValue()) + .map(Map.Entry::getKey) + .sorted() + .toList(); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/admin/platform/WebPlatformStartupValidator.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/admin/platform/WebPlatformStartupValidator.java new file mode 100644 index 00000000..1824b36b --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/admin/platform/WebPlatformStartupValidator.java @@ -0,0 +1,62 @@ +package dev.caskeleton.adapter.inbound.web.admin.platform; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** + * Refuses to start when a declared control is not actually wired. + * + *

At startup, because the alternative is finding out from an incident. A control that is + * configured and not installed behaves exactly like one that is working right up until the moment + * it is needed — the rate limiter that never limits, the budget that never bounds, the problem + * catalog that nothing consults. This session found the third of those by accident, through a + * parity recording; a startup check is what finds the next one on purpose. + * + *

Fail-closed. A validator that logged a warning would be read by nobody: the deployment starts, + * the dashboards are green, and the warning scrolls past in the first thirty seconds of a log + * nobody keeps. + */ +public final class WebPlatformStartupValidator { + + private final List required; + + /** + * A validator over the controls this deployment claims. + * + * @param required the controls that must be wired + */ + public WebPlatformStartupValidator(List required) { + this.required = List.copyOf(Objects.requireNonNull(required, "required")); + } + + /** + * Refuses a snapshot that is missing a required control. + * + * @param snapshot what the running platform reports + * @throws IllegalStateException naming every missing control at once + */ + public void validate(WebPlatformSnapshot snapshot) { + Objects.requireNonNull(snapshot, "snapshot"); + List missing = new ArrayList<>(); + for (String control : required) { + if (!Boolean.TRUE.equals(snapshot.installedControls().get(control))) { + missing.add(control); + } + } + if (!missing.isEmpty()) { + // Every one at once. Reporting the first sends an operator through as many restarts as there + // are problems, and each restart is a deploy. + throw new IllegalStateException( + "the web platform declares controls that are not wired: " + + missing + + ". A configured-but-uninstalled control is indistinguishable from a working one" + + " until the moment it is needed, so startup fails here rather than in production."); + } + if (snapshot.problemCodes().isEmpty()) { + throw new IllegalStateException( + "no problem catalog is installed; every failure would be answered by the framework's" + + " own document, which carries no code for a client to branch on"); + } + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/admin/route/RouteInventoryMismatchException.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/admin/route/RouteInventoryMismatchException.java new file mode 100644 index 00000000..669018e7 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/admin/route/RouteInventoryMismatchException.java @@ -0,0 +1,18 @@ +package dev.caskeleton.adapter.inbound.web.admin.route; + +/** + * The routes this deployment actually serves are not the routes it declared. + * + *

The failure the release gate is built around. A route inventory is only worth having if a + * disagreement with the approved manifest stops the release: an endpoint that appears without a + * review is an endpoint whose authorization, budget and idempotency policy nobody chose. + */ +public final class RouteInventoryMismatchException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + /** Creates the failure. */ + public RouteInventoryMismatchException(String message) { + super(message); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/admin/route/SpringMvcRouteInventoryCollector.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/admin/route/SpringMvcRouteInventoryCollector.java new file mode 100644 index 00000000..8865cb34 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/admin/route/SpringMvcRouteInventoryCollector.java @@ -0,0 +1,138 @@ +package dev.caskeleton.adapter.inbound.web.admin.route; + +import dev.caskeleton.adapter.inbound.web.core.ApiMajorVersion; +import dev.caskeleton.adapter.inbound.web.core.WebOperationName; +import dev.caskeleton.adapter.inbound.web.core.WebRouteId; +import dev.caskeleton.adapter.inbound.web.operation.HttpMethodSemantic; +import dev.caskeleton.adapter.inbound.web.versioning.ApiDeprecationPolicy; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.mvc.method.RequestMappingInfo; +import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; + +/** + * Reads the routes Spring MVC actually registered. + * + *

From the handler mapping rather than from annotations. The mapping is what the dispatcher will + * consult, so it is the only source that reflects path prefixes, conditional registration and any + * programmatic mapping — an annotation scan describes what the code says and the mapping describes + * what the deployment serves, and the gate has to be about the second. + * + *

A mapping with no path or no method is skipped rather than guessed at: Spring permits both, + * and inventing "GET" or "/" for them would put a route in the manifest that nobody can call. + */ +public final class SpringMvcRouteInventoryCollector { + + private final ApiDeprecationPolicy deprecationPolicy; + + /** + * A collector that annotates routes with their deprecation state. + * + * @param deprecationPolicy the registered deprecations + */ + public SpringMvcRouteInventoryCollector(ApiDeprecationPolicy deprecationPolicy) { + this.deprecationPolicy = Objects.requireNonNull(deprecationPolicy, "deprecationPolicy"); + } + + /** + * Collects every registered route. + * + * @param mapping the dispatcher's handler mapping + * @param defaultVersion the version to record for a path that carries none + */ + public WebRouteInventory collect( + RequestMappingHandlerMapping mapping, ApiMajorVersion defaultVersion) { + Objects.requireNonNull(mapping, "mapping"); + Objects.requireNonNull(defaultVersion, "defaultVersion"); + WebRouteInventory inventory = new WebRouteInventory(); + for (var entry : mapping.getHandlerMethods().entrySet()) { + RequestMappingInfo info = entry.getKey(); + HandlerMethod handler = entry.getValue(); + Set patterns = patternsOf(info); + Set methods = + info.getMethodsCondition().getMethods(); + if (patterns.isEmpty() || methods.isEmpty()) { + continue; + } + for (String pattern : patterns) { + for (var method : methods) { + HttpMethodSemantic semantic = semanticOf(method.name()); + if (semantic == null) { + continue; + } + inventory.add(contract(info, handler, pattern, semantic, defaultVersion)); + } + } + } + return inventory; + } + + private WebRouteContract contract( + RequestMappingInfo info, + HandlerMethod handler, + String pattern, + HttpMethodSemantic method, + ApiMajorVersion defaultVersion) { + String routeKey = method.name() + " " + pattern; + var deprecation = deprecationPolicy.find(routeKey); + return new WebRouteContract( + new WebRouteId(routeKey), + operationName(handler), + versionOf(pattern, defaultVersion), + method, + pattern, + List.copyOf(mediaTypes(info.getConsumesCondition().getConsumableMediaTypes())), + List.copyOf(mediaTypes(info.getProducesCondition().getProducibleMediaTypes())), + deprecation.isPresent(), + deprecation.flatMap(route -> route.sunsetAt())); + } + + /** + * The operation name for a handler. + * + *

Derived from the controller and method names, lower-cased and hyphen-joined, because a + * handler that has not declared one still needs an identity the inventory can report — and the + * catalog check will then refuse it, which is the intended outcome for an unregistered route. + */ + private WebOperationName operationName(HandlerMethod handler) { + String type = handler.getBeanType().getSimpleName().replace("Controller", ""); + String raw = (type + "." + handler.getMethod().getName()).toLowerCase(java.util.Locale.ROOT); + String normalised = raw.replaceAll("[^a-z0-9.-]", "-"); + return new WebOperationName(normalised.length() >= 3 ? normalised : "route." + normalised); + } + + private ApiMajorVersion versionOf(String pattern, ApiMajorVersion defaultVersion) { + var matcher = java.util.regex.Pattern.compile("^/api/v([1-9][0-9]*)(?:/|$)").matcher(pattern); + return matcher.find() + ? new ApiMajorVersion(Integer.parseInt(matcher.group(1))) + : defaultVersion; + } + + private static Set patternsOf(RequestMappingInfo info) { + if (info.getPathPatternsCondition() != null) { + return info.getPathPatternsCondition().getPatternValues(); + } + return Set.of(); + } + + private static List mediaTypes(Set types) { + return types.stream().map(Object::toString).sorted().toList(); + } + + private static HttpMethodSemantic semanticOf(String name) { + for (HttpMethodSemantic candidate : HttpMethodSemantic.values()) { + if (candidate.name().equals(name)) { + return candidate; + } + } + return null; + } + + /** The optional deprecation for a route key, exposed for a diagnostic. */ + public Optional deprecationFor(String routeKey) { + return deprecationPolicy.find(routeKey); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/admin/route/WebRouteContract.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/admin/route/WebRouteContract.java new file mode 100644 index 00000000..da2bb43f --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/admin/route/WebRouteContract.java @@ -0,0 +1,69 @@ +package dev.caskeleton.adapter.inbound.web.admin.route; + +import dev.caskeleton.adapter.inbound.web.core.ApiMajorVersion; +import dev.caskeleton.adapter.inbound.web.core.WebOperationName; +import dev.caskeleton.adapter.inbound.web.core.WebRouteId; +import dev.caskeleton.adapter.inbound.web.operation.HttpMethodSemantic; +import java.time.Instant; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +/** + * One published route, as the inventory records it. + * + *

This is the unit the release gate compares. It carries the facts a reviewer needs to decide + * whether an endpoint should exist — what it is called, which version it belongs to, what it reads + * and writes, and whether it is on its way out — and nothing that changes between deployments, so + * the comparison is about the contract rather than about the environment. + * + * @param routeId the route identity + * @param operationName the registered operation this route serves + * @param apiVersion the major version it belongs to + * @param method the HTTP method + * @param pathTemplate the path template, never a resolved URI + * @param consumes the media types the route reads + * @param produces the media types the route writes + * @param deprecated whether the route is deprecated + * @param sunsetAt when it stops being served, when a date has been committed to + */ +public record WebRouteContract( + WebRouteId routeId, + WebOperationName operationName, + ApiMajorVersion apiVersion, + HttpMethodSemantic method, + String pathTemplate, + List consumes, + List produces, + boolean deprecated, + Optional sunsetAt) { + + public WebRouteContract { + Objects.requireNonNull(routeId, "routeId"); + Objects.requireNonNull(operationName, "operationName"); + Objects.requireNonNull(apiVersion, "apiVersion"); + Objects.requireNonNull(method, "method"); + Objects.requireNonNull(pathTemplate, "pathTemplate"); + Objects.requireNonNull(sunsetAt, "sunsetAt"); + if (!pathTemplate.startsWith("/")) { + throw new IllegalArgumentException("route path template must be absolute: " + pathTemplate); + } + consumes = List.copyOf(consumes); + produces = List.copyOf(produces); + if (!deprecated && sunsetAt.isPresent()) { + throw new IllegalArgumentException( + "a route with a sunset date is deprecated by definition: " + pathTemplate); + } + } + + /** + * The key two routes may not share. + * + *

Method, path and version together. Two handlers on the same triple is not a routing + * ambiguity the framework resolves predictably — which one wins depends on registration order, + * and registration order depends on classpath scanning. + */ + public String uniquenessKey() { + return method.name() + " " + apiVersion.pathSegment() + " " + pathTemplate; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/admin/route/WebRouteInventory.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/admin/route/WebRouteInventory.java new file mode 100644 index 00000000..84f36600 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/admin/route/WebRouteInventory.java @@ -0,0 +1,104 @@ +package dev.caskeleton.adapter.inbound.web.admin.route; + +import dev.caskeleton.adapter.inbound.web.core.WebOperationName; +import dev.caskeleton.adapter.inbound.web.operation.WebOperationCatalog; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.TreeMap; + +/** + * Every route this deployment serves, collected and checked. + * + *

Two rules, applied as routes arrive rather than at the end. A duplicate method-path-version is + * refused because which handler wins depends on registration order and registration order depends + * on classpath scanning — a routing decision nobody made. An operation name outside the catalog is + * refused because it means an endpoint reached production with no budget, authorization or + * idempotency policy. + * + *

The inventory is bounded and sorted so two runs of the same build produce the same manifest; a + * release gate that compares against a manifest cannot tolerate ordering that varies with scanning. + */ +public final class WebRouteInventory { + + private final Map routes = new TreeMap<>(); + + /** + * Records a route. + * + * @throws RouteInventoryMismatchException when the method-path-version triple is already taken + */ + public void add(WebRouteContract route) { + Objects.requireNonNull(route, "route"); + WebRouteContract existing = routes.putIfAbsent(route.uniquenessKey(), route); + if (existing != null) { + throw new RouteInventoryMismatchException( + "duplicate route " + + route.uniquenessKey() + + "; which handler wins would depend on classpath scanning order"); + } + } + + /** + * Refuses any route whose operation is not registered. + * + * @param catalog the registered operations + * @throws RouteInventoryMismatchException naming every unregistered operation at once + */ + public void requireRegisteredOperations(WebOperationCatalog catalog) { + Objects.requireNonNull(catalog, "catalog"); + List unregistered = new ArrayList<>(); + for (WebRouteContract route : routes.values()) { + WebOperationName name = route.operationName(); + try { + catalog.require(name); + } catch (RuntimeException unknown) { + unregistered.add(route.uniquenessKey() + " -> " + name.value()); + } + } + if (!unregistered.isEmpty()) { + throw new RouteInventoryMismatchException( + "routes serve unregistered operations, so they have no budget, authorization or" + + " idempotency policy: " + + unregistered); + } + } + + /** + * Compares the served routes against an approved manifest. + * + * @param approvedKeys the uniqueness keys the manifest declares + * @throws RouteInventoryMismatchException when either side has something the other does not + */ + public void requireMatches(java.util.Set approvedKeys) { + Objects.requireNonNull(approvedKeys, "approvedKeys"); + List added = new ArrayList<>(routes.keySet()); + added.removeAll(approvedKeys); + List removed = new ArrayList<>(approvedKeys); + removed.removeAll(routes.keySet()); + if (!added.isEmpty() || !removed.isEmpty()) { + throw new RouteInventoryMismatchException( + "the served routes and the approved manifest disagree; added=" + + added + + " removed=" + + removed); + } + } + + /** Every recorded route, in a deterministic order. */ + public Map routes() { + return Map.copyOf(new LinkedHashMap<>(routes)); + } + + /** The uniqueness keys, for writing or comparing a manifest. */ + public java.util.Set keys() { + return java.util.Set.copyOf(routes.keySet()); + } + + /** How many routes are served. */ + public int size() { + return routes.size(); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/admission/AdmissionDecision.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/admission/AdmissionDecision.java new file mode 100644 index 00000000..91364220 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/admission/AdmissionDecision.java @@ -0,0 +1,59 @@ +package dev.caskeleton.adapter.inbound.web.admission; + +import java.time.Duration; +import java.util.Objects; +import java.util.Optional; + +/** + * Whether the service has room to run this request now. + * + *

Distinct from a quota decision in what it means and in what it is answered with. A quota is + * about the caller and is 429; this is about the service and is 503. The permit is carried here + * rather than returned separately so that releasing it is impossible to forget in one branch and + * not another. + * + * @param admitted whether the request may run + * @param permit what to close when it finishes, present only when admitted + * @param retryAfter how long to wait, present only when refused + * @param waited how long the request queued before this decision + */ +public record AdmissionDecision( + boolean admitted, + Optional permit, + Optional retryAfter, + Duration waited) { + + public AdmissionDecision { + Objects.requireNonNull(permit, "permit"); + Objects.requireNonNull(retryAfter, "retryAfter"); + Objects.requireNonNull(waited, "waited"); + if (admitted != permit.isPresent()) { + throw new IllegalArgumentException( + "an admitted request holds a permit and a refused one does not; anything else leaks" + + " capacity or releases what it never took"); + } + if (admitted == retryAfter.isPresent()) { + throw new IllegalArgumentException("only a refused request is told when to come back"); + } + } + + /** + * The request may run. + * + * @param permit what to close when it finishes + * @param waited how long it queued + */ + public static AdmissionDecision admitted(AdmissionPermit permit, Duration waited) { + return new AdmissionDecision(true, Optional.of(permit), Optional.empty(), waited); + } + + /** + * The service has no room. + * + * @param retryAfter how long to wait + * @param waited how long it queued before being turned away + */ + public static AdmissionDecision refused(Duration retryAfter, Duration waited) { + return new AdmissionDecision(false, Optional.empty(), Optional.of(retryAfter), waited); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/admission/AdmissionPermit.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/admission/AdmissionPermit.java new file mode 100644 index 00000000..178b4490 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/admission/AdmissionPermit.java @@ -0,0 +1,21 @@ +package dev.caskeleton.adapter.inbound.web.admission; + +/** + * The right to occupy one execution slot, given up when closed. + * + *

{@link AutoCloseable} so the release is a {@code try}-with-resources rather than a {@code + * finally} somebody has to remember. A permit that is not released is capacity the service never + * gets back, and the symptom — throughput decaying towards zero over hours — is one of the harder + * ones to attribute. + * + *

{@code close} is idempotent in every implementation here. A double release would return + * capacity that was never taken, which is the same bug in the opposite direction and admits more + * concurrent work than the profile allows. + */ +@FunctionalInterface +public interface AdmissionPermit extends AutoCloseable { + + /** Gives the slot back. Calling twice releases nothing extra. */ + @Override + void close(); +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/admission/AdmissionProfile.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/admission/AdmissionProfile.java new file mode 100644 index 00000000..16681817 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/admission/AdmissionProfile.java @@ -0,0 +1,80 @@ +package dev.caskeleton.adapter.inbound.web.admission; + +import dev.caskeleton.adapter.inbound.web.operation.AdmissionProfileName; +import java.time.Duration; +import java.util.Objects; + +/** + * How much of the service one class of work may occupy. + * + *

Separate profiles for separate work is the point. A single global limit sized for cheap reads + * lets a burst of expensive writes fill it and starve everything; sized for the writes, it is no + * limit at all for the reads. The design asks for at least a write profile and an expensive-query + * profile, and the factories below are those. + * + * @param name the profile's identity + * @param maxConcurrent how many may run at once + * @param maxQueued how many may wait + * @param maxQueueWait how long one may wait before being turned away + */ +public record AdmissionProfile( + AdmissionProfileName name, int maxConcurrent, int maxQueued, Duration maxQueueWait) { + + public AdmissionProfile { + Objects.requireNonNull(name, "name"); + Objects.requireNonNull(maxQueueWait, "maxQueueWait"); + if (maxConcurrent <= 0) { + throw new IllegalArgumentException("a profile that admits nothing is not a profile"); + } + if (maxQueued < 0) { + throw new IllegalArgumentException("a negative queue is not a queue"); + } + // An unbounded queue is the failure this whole mechanism exists to prevent. It converts an + // overload into unbounded latency and memory growth: every caller is accepted, none is served + // in time, and the ones still waiting have long since given up and retried. + if (maxQueued > 10 * maxConcurrent) { + throw new IllegalArgumentException( + "a queue of " + + maxQueued + + " against " + + maxConcurrent + + " concurrent is effectively unbounded: everything is accepted, nothing is served" + + " in time, and the far end has already retried"); + } + if (maxQueueWait.isNegative()) { + throw new IllegalArgumentException("a negative queue wait is not a wait"); + } + if (maxQueueWait.compareTo(Duration.ofSeconds(10)) > 0) { + throw new IllegalArgumentException( + "a queue wait over 10s outlives the client that is waiting; refusing quickly is the" + + " kinder answer and the one that sheds load"); + } + } + + /** + * Global writes: few at once, a short queue, a short wait. + * + *

Writes contend on the same rows and the same connections, so more concurrency past a point + * buys nothing and costs lock contention. + */ + public static AdmissionProfile globalWrite() { + return new AdmissionProfile( + new AdmissionProfileName("global-write"), 32, 64, Duration.ofMillis(250)); + } + + /** + * Expensive queries: fewer still, and turned away rather than queued for long. + * + *

An expensive query that has been waiting is worse than one refused: by the time it runs the + * caller has usually gone, and the work is spent on a response nobody reads. + */ + public static AdmissionProfile expensiveQuery() { + return new AdmissionProfile( + new AdmissionProfileName("expensive-query"), 8, 8, Duration.ofMillis(100)); + } + + /** Ordinary reads: generous, because they are cheap and mostly bounded by IO. */ + public static AdmissionProfile standard() { + return new AdmissionProfile(AdmissionProfileName.standard(), 256, 256, Duration.ofMillis(500)); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/admission/SemaphoreAdmissionController.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/admission/SemaphoreAdmissionController.java new file mode 100644 index 00000000..d00ff806 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/admission/SemaphoreAdmissionController.java @@ -0,0 +1,118 @@ +package dev.caskeleton.adapter.inbound.web.admission; + +import dev.caskeleton.adapter.inbound.web.operation.AdmissionProfileName; +import java.time.Duration; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Bounded concurrency with a bounded queue, per profile. + * + *

A {@link Semaphore} per profile rather than one shared pool, so a saturated write profile + * cannot refuse a read. The queue is a separate counter rather than the semaphore's own waiter + * list, because the semaphore's list is unbounded: without the counter a spike parks every request + * thread on {@code tryAcquire} and the bounded-queue requirement is satisfied only on paper. + * + *

The two bounds do different jobs. Concurrency decides how much work runs; the queue decides + * how much hope is kept. A queue longer than the clients' own timeouts is worse than no queue — + * every slot is filled with a request whose caller has already given up and retried, so the service + * does the work twice and answers neither in time. + */ +public final class SemaphoreAdmissionController implements WebAdmissionController { + + private record Gate(AdmissionProfile profile, Semaphore permits, AtomicInteger queued) {} + + private final Map gates = new ConcurrentHashMap<>(); + + /** + * A controller over the given profiles. + * + * @param profiles what may run, and how much of it + */ + public SemaphoreAdmissionController(AdmissionProfile... profiles) { + Objects.requireNonNull(profiles, "profiles"); + for (AdmissionProfile profile : profiles) { + // Fair, so a request that has been queued longest goes next. Unfair acquisition is faster + // and starves the unlucky: under sustained load a few requests wait past every timeout while + // arrivals behind them are served, and the latency tail stops resembling the median at all. + gates.put( + profile.name(), + new Gate(profile, new Semaphore(profile.maxConcurrent(), true), new AtomicInteger())); + } + } + + /** A controller over the three standard profiles. */ + public static SemaphoreAdmissionController standard() { + return new SemaphoreAdmissionController( + AdmissionProfile.standard(), + AdmissionProfile.globalWrite(), + AdmissionProfile.expensiveQuery()); + } + + @Override + public AdmissionDecision admit(AdmissionProfileName profileName) { + Objects.requireNonNull(profileName, "profileName"); + Gate gate = gates.get(profileName); + if (gate == null) { + throw new IllegalArgumentException( + "no admission profile named " + + profileName + + "; an operation whose profile is unregistered would run with no bound at all"); + } + + // Fast path: a free slot is taken without ever joining the queue, so an idle service adds no + // queue accounting to the common case. + if (gate.permits().tryAcquire()) { + return AdmissionDecision.admitted(permitFor(gate), Duration.ZERO); + } + + int queueDepth = gate.queued().incrementAndGet(); + try { + if (queueDepth > gate.profile().maxQueued()) { + return AdmissionDecision.refused(retryAfterFor(gate), Duration.ZERO); + } + long startedAt = System.nanoTime(); + boolean acquired = + gate.permits().tryAcquire(gate.profile().maxQueueWait().toNanos(), TimeUnit.NANOSECONDS); + Duration waited = Duration.ofNanos(System.nanoTime() - startedAt); + return acquired + ? AdmissionDecision.admitted(permitFor(gate), waited) + : AdmissionDecision.refused(retryAfterFor(gate), waited); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + return AdmissionDecision.refused(retryAfterFor(gate), Duration.ZERO); + } finally { + gate.queued().decrementAndGet(); + } + } + + /** How many slots the profile currently has free. */ + public int availablePermits(AdmissionProfileName profileName) { + Gate gate = gates.get(profileName); + return gate == null ? 0 : gate.permits().availablePermits(); + } + + private static AdmissionPermit permitFor(Gate gate) { + AtomicBoolean released = new AtomicBoolean(); + return () -> { + // Guarded, because a double release hands back a slot that was never taken and quietly + // raises the concurrency bound above what the profile allows. + if (released.compareAndSet(false, true)) { + gate.permits().release(); + } + }; + } + + private static Duration retryAfterFor(Gate gate) { + // The queue wait, not a fixed second: it is the timescale on which this profile actually + // drains, so it is the honest answer to "when should I come back". + return gate.profile().maxQueueWait().isZero() + ? Duration.ofMillis(100) + : gate.profile().maxQueueWait(); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/admission/WebAdmissionController.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/admission/WebAdmissionController.java new file mode 100644 index 00000000..b14a7024 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/admission/WebAdmissionController.java @@ -0,0 +1,21 @@ +package dev.caskeleton.adapter.inbound.web.admission; + +import dev.caskeleton.adapter.inbound.web.operation.AdmissionProfileName; + +/** + * Decides whether the service has room to run a request now. + * + *

Load shedding, not rate limiting. This looks only at what the service is currently doing and + * knows nothing about who is calling; the limiter looks only at the caller and knows nothing about + * the service's state. Keeping them apart is what lets a well-behaved caller be told 503 during a + * spike and an abusive one be told 429 while the service is idle. + */ +public interface WebAdmissionController { + + /** + * Asks for a slot, waiting up to the profile's queue budget. + * + * @param profile which class of work this is + */ + AdmissionDecision admit(AdmissionProfileName profile); +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/WebAdvancedFeature.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/WebAdvancedFeature.java new file mode 100644 index 00000000..3392a3f5 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/WebAdvancedFeature.java @@ -0,0 +1,74 @@ +package dev.caskeleton.adapter.inbound.web.advanced; + +import java.util.Locale; + +/** + * The web capabilities that are not part of Stable, each behind its own flag. + * + *

One flag per capability, not one for "advanced". They have nothing in common operationally: + * virtual threads change how every request is scheduled, streaming changes how long a response + * holds a connection, XML adds a parser with a decades-long history of entity-expansion attacks. A + * single switch would make those one decision, and a deployment that wanted the first would be + * given the third. + * + *

Every constant is off unless named. + */ +public enum WebAdvancedFeature { + + /** + * A virtual-thread executor for MVC request handling. + * + *

Changes the scheduling model, not the concurrency budget. See {@code + * VirtualThreadAdmissionGuard} for why those are different things. + */ + MVC_VIRTUAL_THREADS, + + /** A bounded, registered offload for blocking work called from WebFlux. */ + WEBFLUX_BLOCKING_BRIDGE, + + /** RFC 7396 {@code application/merge-patch+json}. */ + JSON_MERGE_PATCH, + + /** RFC 6902 {@code application/json-patch+json}. */ + JSON_PATCH, + + /** Server-sent events. */ + SSE, + + /** {@code application/x-ndjson} streaming. */ + NDJSON, + + /** RFC 7464 {@code application/json-seq} streaming. */ + JSON_SEQUENCE, + + /** Functional WebFlux routes, registered against the operation catalog. */ + FUNCTIONAL_WEBFLUX, + + /** {@code application/cbor} as a representation. */ + CBOR, + + /** {@code application/xml} as a representation. */ + XML, + + /** OpenAPI 3.2 generated alongside the Stable 3.1 snapshot. */ + OPENAPI_32, + + /** Draft {@code RateLimit} and {@code RateLimit-Policy} response headers. */ + RATELIMIT_DRAFT_HEADERS; + + /** The property that turns this on. */ + public String propertyName() { + return "backend.web.advanced." + name().toLowerCase(Locale.ROOT).replace('_', '-') + ".enabled"; + } + + /** + * Whether enabling this changes behaviour for requests that do not use it. + * + *

The distinction that decides how much soak a capability needs. A codec only affects requests + * that negotiate it; a virtual-thread executor affects every request in the process, and the + * blocking bridge affects the event loop that every reactive request shares. + */ + public boolean affectsUnrelatedRequests() { + return this == MVC_VIRTUAL_THREADS || this == WEBFLUX_BLOCKING_BRIDGE; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/WebAdvancedFeatureFlags.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/WebAdvancedFeatureFlags.java new file mode 100644 index 00000000..2e95af55 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/WebAdvancedFeatureFlags.java @@ -0,0 +1,47 @@ +package dev.caskeleton.adapter.inbound.web.advanced; + +import java.util.EnumSet; +import java.util.Objects; +import java.util.Set; + +/** + * Which Advanced capabilities this deployment named. + * + *

A value rather than a property lookup at each call site, so that "what is on" is one thing an + * operator can print rather than a set of conditions scattered through the configuration. + */ +public final class WebAdvancedFeatureFlags { + + private final Set enabled; + + private WebAdvancedFeatureFlags(Set enabled) { + this.enabled = Set.copyOf(enabled); + } + + /** Nothing enabled. The default, and what a Stable deployment has. */ + public static WebAdvancedFeatureFlags none() { + return new WebAdvancedFeatureFlags(EnumSet.noneOf(WebAdvancedFeature.class)); + } + + /** Exactly these. */ + public static WebAdvancedFeatureFlags of(WebAdvancedFeature... features) { + Objects.requireNonNull(features, "features"); + return new WebAdvancedFeatureFlags(Set.of(features)); + } + + /** Whether a capability is on. */ + public boolean enabled(WebAdvancedFeature feature) { + Objects.requireNonNull(feature, "feature"); + return enabled.contains(feature); + } + + /** Everything that is on, for the startup report. */ + public Set all() { + return enabled; + } + + /** Whether this deployment behaves as a Stable one for requests that use no Advanced feature. */ + public boolean stableBehaviourPreserved() { + return enabled.stream().noneMatch(WebAdvancedFeature::affectsUnrelatedRequests); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/blockingbridge/BlockingBridgeBudget.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/blockingbridge/BlockingBridgeBudget.java new file mode 100644 index 00000000..9548f669 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/blockingbridge/BlockingBridgeBudget.java @@ -0,0 +1,97 @@ +package dev.caskeleton.adapter.inbound.web.advanced.blockingbridge; + +import java.time.Duration; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.LongAdder; + +/** + * The bounded pool the bridge offloads into, and what it observed. + * + *

Separate from the bridge itself so the accounting can be asserted without a Reactor pipeline. + * The number that matters — the peak concurrency actually reached — is invisible from throughput + * and from latency; a bridge whose bound is not applied looks exactly like one whose bound is + * generous, right up until the pool is the heap. + */ +public final class BlockingBridgeBudget { + + private static final String UNREGISTERED = ""; + + private final BlockingBridgeProfile profile; + private final Semaphore permits; + private final AtomicInteger inFlight = new AtomicInteger(); + private final AtomicInteger peakConcurrency = new AtomicInteger(); + private final Map rejectionsByOperation = new ConcurrentHashMap<>(); + + public BlockingBridgeBudget(BlockingBridgeProfile profile) { + this.profile = Objects.requireNonNull(profile, "profile"); + this.permits = new Semaphore(profile.maxConcurrency(), true); + } + + /** The profile. */ + public BlockingBridgeProfile profile() { + return profile; + } + + /** + * Take a slot, or refuse. + * + * @param operation which registered operation + * @throws BlockingBridgeRejectedException if unregistered or no slot became free in time + */ + public void acquire(String operation) throws InterruptedException { + Objects.requireNonNull(operation, "operation"); + if (!profile.registered(operation)) { + record(operation); + throw new BlockingBridgeRejectedException( + operation, BlockingBridgeRejectedException.Reason.NOT_REGISTERED); + } + Duration timeout = profile.queueTimeout(); + if (!permits.tryAcquire(timeout.toMillis(), TimeUnit.MILLISECONDS)) { + record(operation); + throw new BlockingBridgeRejectedException( + operation, BlockingBridgeRejectedException.Reason.QUEUE_TIMEOUT); + } + int current = inFlight.incrementAndGet(); + peakConcurrency.accumulateAndGet(current, Math::max); + } + + /** Give the slot back. */ + public void release() { + inFlight.decrementAndGet(); + permits.release(); + } + + /** How many are running now. */ + public int inFlight() { + return inFlight.get(); + } + + /** The most that ever ran at once. Must never exceed the configured bound. */ + public int peakConcurrency() { + return peakConcurrency.get(); + } + + /** How many offloads this operation had refused. */ + public long rejectionsFor(String operation) { + LongAdder counter = rejectionsByOperation.get(operation); + return counter == null ? 0L : counter.sum(); + } + + /** Refusals of operations nobody registered, under one key rather than one key each. */ + public long unregisteredRejections() { + return rejectionsFor(UNREGISTERED); + } + + private void record(String operation) { + // Only registered names become keys. An unregistered one is counted under a single sentinel, + // because the caller supplies that string and a map keyed on it grows with whatever is passed + // — which is the same unbounded-cardinality problem a metric tagged with client input has. + String key = profile.registered(operation) ? operation : UNREGISTERED; + rejectionsByOperation.computeIfAbsent(key, ignored -> new LongAdder()).increment(); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/blockingbridge/BlockingBridgeProfile.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/blockingbridge/BlockingBridgeProfile.java new file mode 100644 index 00000000..d2b88181 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/blockingbridge/BlockingBridgeProfile.java @@ -0,0 +1,50 @@ +package dev.caskeleton.adapter.inbound.web.advanced.blockingbridge; + +import java.time.Duration; +import java.util.Objects; +import java.util.Set; + +/** + * Which blocking operations may be offloaded, and how much of the offload pool each may use. + * + *

The registration is the point. Reactor's {@code boundedElastic()} is available from anywhere + * and unbounded in practice — it grows to ten times the CPU count *per scheduler* and queues + * without limit beyond that — so a controller that calls it has silently opted the whole + * application into an unbounded thread pool. Every such call site is invisible until the pool is + * the thing consuming the heap. + * + *

A registered set makes the offloads enumerable. The concurrency bound makes them survivable: + * without it, a slow dependency's callers accumulate on the pool and starve the fast ones, which + * looks like the fast dependency having failed. + * + * @param registeredOperations the operations permitted to offload + * @param maxConcurrency how many may run at once + * @param queueTimeout how long a caller waits for a slot before being refused + */ +public record BlockingBridgeProfile( + Set registeredOperations, int maxConcurrency, Duration queueTimeout) { + + public BlockingBridgeProfile { + registeredOperations = + Set.copyOf(Objects.requireNonNull(registeredOperations, "registeredOperations")); + Objects.requireNonNull(queueTimeout, "queueTimeout"); + if (registeredOperations.isEmpty()) { + throw new IllegalArgumentException( + "a bridge with no registered operation refuses everything; if nothing blocks, do not " + + "enable the bridge"); + } + if (maxConcurrency < 1) { + throw new IllegalArgumentException("an offload pool of zero runs nothing"); + } + if (queueTimeout.isNegative() || queueTimeout.isZero()) { + throw new IllegalArgumentException( + "an unbounded queue wait means a slow dependency's callers accumulate until the heap " + + "does, and the fast dependencies starve behind them"); + } + } + + /** Whether an operation may use the bridge. */ + public boolean registered(String operation) { + return operation != null && registeredOperations.contains(operation); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/blockingbridge/BlockingBridgeRejectedException.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/blockingbridge/BlockingBridgeRejectedException.java new file mode 100644 index 00000000..ec04122d --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/blockingbridge/BlockingBridgeRejectedException.java @@ -0,0 +1,47 @@ +package dev.caskeleton.adapter.inbound.web.advanced.blockingbridge; + +import java.util.Objects; + +/** + * A blocking offload was refused. + * + *

Two reasons, kept apart because they mean different things to whoever is looking. An + * unregistered operation is a programming error found at runtime — somebody called the bridge from + * a path nobody declared. A full pool is a capacity signal. + */ +public final class BlockingBridgeRejectedException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + private final transient String operation; + private final transient Reason reason; + + public BlockingBridgeRejectedException(String operation, Reason reason) { + super("blocking offload refused for '" + operation + "': " + reason); + this.operation = Objects.requireNonNull(operation, "operation"); + this.reason = Objects.requireNonNull(reason, "reason"); + } + + /** Which operation. */ + public String operation() { + return operation; + } + + /** Why. */ + public Reason reason() { + return reason; + } + + /** Why an offload was refused. */ + public enum Reason { + + /** Nobody declared this operation. A bug, not a capacity signal. */ + NOT_REGISTERED, + + /** The bounded pool is full. */ + CAPACITY_EXHAUSTED, + + /** No slot became available within the queue timeout. */ + QUEUE_TIMEOUT + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/codec/CodecBudget.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/codec/CodecBudget.java new file mode 100644 index 00000000..5195ccde --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/codec/CodecBudget.java @@ -0,0 +1,61 @@ +package dev.caskeleton.adapter.inbound.web.advanced.codec; + +import java.util.Objects; + +/** + * The decode limits that apply per representation. + * + *

Per representation rather than shared, because the same byte count means very different + * amounts of work in each. A megabyte of JSON is a megabyte of text to parse; a megabyte of CBOR + * can declare an array of a billion elements in a handful of bytes, and a megabyte of XML with a + * DTD can expand to whatever the entity nesting says. Applying the JSON body limit to all three + * bounds the bytes and not the work. + * + * @param representation which codec + * @param maxBodyBytes the ceiling on the encoded body + * @param maxNestingDepth how deeply structures may nest + * @param maxCollectionSize how many elements one array or object may hold + */ +public record CodecBudget( + WebRepresentation representation, + int maxBodyBytes, + int maxNestingDepth, + int maxCollectionSize) { + + public CodecBudget { + Objects.requireNonNull(representation, "representation"); + if (maxBodyBytes < 1) { + throw new IllegalArgumentException("a body ceiling of zero admits nothing"); + } + if (maxNestingDepth < 1) { + throw new IllegalArgumentException( + "a depth limit is required: every one of these formats is recursive, and recursion " + + "without a bound is a stack overflow that no exception handler can turn into a 400"); + } + if (maxCollectionSize < 1) { + throw new IllegalArgumentException( + "a collection limit is required: a binary format declares a length before its contents, " + + "so a few bytes can ask for an allocation the body size never bounded"); + } + } + + /** Conventional bounds: 1MB, 32 deep, 10,000 elements. */ + public static CodecBudget conventional(WebRepresentation representation) { + return new CodecBudget(representation, 1_048_576, 32, 10_000); + } + + /** Whether a body may be decoded at all. */ + public boolean bodyWithinBounds(int bodyBytes) { + return bodyBytes >= 0 && bodyBytes <= maxBodyBytes; + } + + /** Whether decoding may descend further. */ + public boolean mayDescend(int currentDepth) { + return currentDepth < maxNestingDepth; + } + + /** Whether a collection may grow further. */ + public boolean mayGrow(int currentSize) { + return currentSize < maxCollectionSize; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/codec/RepresentationNegotiationPolicy.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/codec/RepresentationNegotiationPolicy.java new file mode 100644 index 00000000..03bebdb1 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/codec/RepresentationNegotiationPolicy.java @@ -0,0 +1,84 @@ +package dev.caskeleton.adapter.inbound.web.advanced.codec; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; + +/** + * Which representation to answer with, given what the client asked for and what the route offers. + * + *

Three gates, all of which must agree, and the reason for three rather than one is that they + * fail differently. The feature flag says the codec exists in this deployment at all. The route's + * {@code produces} says this particular endpoint was designed to emit it — an endpoint whose + * response contains a field that only serializes sensibly as JSON should not be answering in CBOR + * because somebody sent an {@code Accept} header. The client allowlist says a caller was actually + * expected to use it. + * + *

Content negotiation alone is not enough, which is the thing that gets skipped: an {@code + * Accept: application/xml} header from an arbitrary client is not evidence that the route was ever + * tested against the XML codec. + */ +public final class RepresentationNegotiationPolicy { + + private final Set enabledRepresentations; + private final Set allowedClients; + + /** + * @param enabledRepresentations what this deployment turned on + * @param allowedClients which client identifiers may negotiate a non-JSON representation + */ + public RepresentationNegotiationPolicy( + Set enabledRepresentations, Set allowedClients) { + this.enabledRepresentations = + Set.copyOf(Objects.requireNonNull(enabledRepresentations, "enabledRepresentations")); + this.allowedClients = Set.copyOf(Objects.requireNonNull(allowedClients, "allowedClients")); + } + + /** JSON only, which is what a Stable deployment has. */ + public static RepresentationNegotiationPolicy jsonOnly() { + return new RepresentationNegotiationPolicy(Set.of(WebRepresentation.JSON), Set.of()); + } + + /** + * Choose a representation. + * + * @param accepted the client's ordered preferences + * @param routeProduces what the route declares it can emit + * @param clientId who is asking, absent for an unidentified caller + * @return the chosen representation, empty when nothing acceptable is available + */ + public Optional negotiate( + List accepted, Set routeProduces, Optional clientId) { + Objects.requireNonNull(accepted, "accepted"); + Objects.requireNonNull(routeProduces, "routeProduces"); + Objects.requireNonNull(clientId, "clientId"); + for (String candidate : accepted) { + Optional resolved = WebRepresentation.fromMediaType(candidate); + if (resolved.isEmpty()) { + continue; + } + WebRepresentation representation = resolved.get(); + if (permitted(representation, routeProduces, clientId)) { + return Optional.of(representation); + } + } + // No fallback to JSON. A client that asked only for CBOR and gets JSON receives bytes it will + // try to parse as CBOR, which fails somewhere far from here; 406 says what happened. + return Optional.empty(); + } + + private boolean permitted( + WebRepresentation representation, + Set routeProduces, + Optional clientId) { + if (!routeProduces.contains(representation) + || !enabledRepresentations.contains(representation)) { + return false; + } + if (!representation.requiresOptIn()) { + return true; + } + return clientId.filter(allowedClients::contains).isPresent(); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/codec/SecureXmlInputFactory.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/codec/SecureXmlInputFactory.java new file mode 100644 index 00000000..94b09860 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/codec/SecureXmlInputFactory.java @@ -0,0 +1,59 @@ +package dev.caskeleton.adapter.inbound.web.advanced.codec; + +import javax.xml.stream.XMLInputFactory; +import javax.xml.stream.XMLResolver; +import javax.xml.stream.XMLStreamException; + +/** + * An {@link XMLInputFactory} with the two features that make XML dangerous turned off. + * + *

Both defaults are on, and both are old enough that "everyone knows" — which is exactly why + * they keep shipping. Neither produces an error when it fires; the parse succeeds and the document + * contains something it should not. + * + *

{@code SUPPORT_DTD=false} disables the DTD subset entirely. That stops the billion-laughs + * expansion, where ten nested internal entities each referencing the previous ten expand a + * two-hundred-byte document into gigabytes of heap — before any application code sees it, and + * without a single external request. + * + *

{@code isSupportingExternalEntities=false} plus a throwing resolver stops XXE: an entity + * declared {@code SYSTEM "file:///etc/passwd"} is resolved by the parser and its content + * substituted into the document, so a field in the resulting DTO holds the file. The resolver is + * belt and braces — the property alone is enough on a conformant implementation, and the throwing + * resolver means a non-conformant one fails loudly instead of reading the file. + */ +public final class SecureXmlInputFactory { + + private SecureXmlInputFactory() {} + + /** A factory that cannot be talked into reading the filesystem or expanding entities. */ + public static XMLInputFactory create() { + XMLInputFactory factory = XMLInputFactory.newFactory(); + factory.setProperty(XMLInputFactory.SUPPORT_DTD, false); + factory.setProperty("javax.xml.stream.isSupportingExternalEntities", false); + factory.setXMLResolver(refusingResolver()); + return factory; + } + + /** A resolver that refuses rather than resolves. */ + public static XMLResolver refusingResolver() { + return (publicId, systemId, baseUri, namespace) -> { + throw new XMLStreamException( + "external entity resolution is disabled; an entity that resolves is a file read or an " + + "outbound request performed by the parser on the sender's behalf"); + }; + } + + /** + * Whether a factory is configured safely. + * + *

Exists so a test can assert on a factory the application built, rather than on one the test + * built. The failure this guards is a configuration path that constructs its own factory and + * never reaches {@link #create()}. + */ + public static boolean secure(XMLInputFactory factory) { + return Boolean.FALSE.equals(factory.getProperty(XMLInputFactory.SUPPORT_DTD)) + && Boolean.FALSE.equals( + factory.getProperty("javax.xml.stream.isSupportingExternalEntities")); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/codec/WebCborMapperFactory.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/codec/WebCborMapperFactory.java new file mode 100644 index 00000000..0a03fa6e --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/codec/WebCborMapperFactory.java @@ -0,0 +1,87 @@ +package dev.caskeleton.adapter.inbound.web.advanced.codec; + +import java.util.Objects; +import tools.jackson.core.StreamReadConstraints; +import tools.jackson.databind.DeserializationFeature; +import tools.jackson.databind.MapperFeature; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.cfg.CoercionAction; +import tools.jackson.databind.cfg.CoercionInputShape; +import tools.jackson.databind.type.LogicalType; +import tools.jackson.dataformat.cbor.CBORFactory; +import tools.jackson.dataformat.cbor.CBORMapper; + +/** + * The CBOR mapper, configured to be exactly as strict as the JSON one and no looser. + * + *

That equivalence is the whole requirement, and it is easy to lose. A binary codec is usually + * added for compactness, and the natural way to add one is to build a mapper with its defaults — + * which are looser than this application's JSON profile in two specific ways. Unknown properties + * are ignored, so a client that misspells a field gets a silent default instead of a 400. And + * scalars coerce, so the string {@code "5"} becomes the number 5 in a field the JSON profile would + * have refused. + * + *

The result is a representation that accepts requests the primary one rejects — which is a + * validation bypass reachable by changing one header. + * + *

The stream constraints are separate from the JSON ones and stricter in the dimension that + * matters. CBOR declares a collection's length before its contents, so a handful of bytes can ask + * the decoder for an enormous allocation; the body-size limit that bounds JSON bounds the bytes and + * not the work. + */ +public final class WebCborMapperFactory { + + private WebCborMapperFactory() {} + + /** + * A mapper matching the JSON profile's strictness. + * + * @param budget supplies the decode bounds this representation runs under + */ + public static ObjectMapper create(CodecBudget budget) { + Objects.requireNonNull(budget, "budget"); + if (budget.representation() != WebRepresentation.CBOR) { + throw new IllegalArgumentException( + "a CBOR mapper built from another representation's budget would enforce the wrong" + + " limits: " + + budget.representation()); + } + requireBackend(); + CBORFactory factory = + CBORFactory.builder() + .streamReadConstraints( + StreamReadConstraints.builder() + .maxNestingDepth(budget.maxNestingDepth()) + .maxDocumentLength(budget.maxBodyBytes()) + .build()) + .build(); + return CBORMapper.builder(factory) + // The three that make this the JSON profile rather than Jackson's defaults. Without the + // first, a misspelled field is silently ignored; without the other two, "5" becomes 5 in a + // numeric field and "true" becomes true in a boolean one. + .enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .disable(MapperFeature.ALLOW_COERCION_OF_SCALARS) + .withCoercionConfigDefaults( + config -> config.setCoercion(CoercionInputShape.String, CoercionAction.Fail)) + .withCoercionConfig( + LogicalType.Textual, + config -> config.setCoercion(CoercionInputShape.Integer, CoercionAction.Fail)) + .build(); + } + + /** + * Refuse with a sentence rather than a {@code NoClassDefFoundError} naming a Jackson class. + * + *

The backend is compile-only, so this is the state a deployment reaches by enabling the + * capability and not adding the jar. The error it would otherwise get comes from inside a mapper + * builder and says nothing about the decision that caused it. + */ + private static void requireBackend() { + if (!WebRepresentation.CBOR.available()) { + throw new IllegalStateException( + "the CBOR capability is enabled but tools.jackson.dataformat:jackson-dataformat-cbor is" + + " not on the runtime classpath; it is compile-only here so that enabling it stays" + + " a deployment's decision rather than every deployment's default"); + } + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/codec/WebRepresentation.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/codec/WebRepresentation.java new file mode 100644 index 00000000..2949cf41 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/codec/WebRepresentation.java @@ -0,0 +1,90 @@ +package dev.caskeleton.adapter.inbound.web.advanced.codec; + +import java.util.Locale; +import java.util.Optional; + +/** + * The representations a route may produce beyond JSON. + * + *

An enum rather than free media-type strings, because each of these is a parser and adding one + * is a security decision, not a formatting preference. The set being closed is what makes "which + * parsers can a request reach" a question with an answer. + */ +public enum WebRepresentation { + + /** The Stable representation. Always available. */ + JSON("application/json"), + + /** Compact binary. Same DTOs, same validation, a second decoder. */ + CBOR("application/cbor"), + + /** + * XML. + * + *

The one with a genuinely dangerous default. An {@code XMLInputFactory} out of the box + * resolves external entities and expands internal ones, which is XXE and the billion-laughs + * expansion respectively — file disclosure and a memory exhaustion from a two-line document. + */ + XML("application/xml"); + + private final String mediaType; + + WebRepresentation(String mediaType) { + this.mediaType = mediaType; + } + + /** The media type. */ + public String mediaType() { + return mediaType; + } + + /** Whether this representation needs an explicit opt-in. */ + public boolean requiresOptIn() { + return this != JSON; + } + + /** + * Whether this representation's format backend is on the runtime classpath. + * + *

CBOR and XML are compile-only here, so a deployment that enables one of them adds the jar. + * That is deliberate — putting either on every deployment's runtime classpath makes Spring Boot + * register a mapper bean for it, and for XML a message converter too, which turns a capability + * that is off by default into a parser every request can reach. + * + *

The cost of that choice is this method. Without it the missing jar surfaces as a {@code + * NoClassDefFoundError} from inside a mapper builder, which names a Jackson class and not the + * decision that caused it. + */ + public boolean available() { + return switch (this) { + case JSON -> true; + case CBOR -> classPresent("tools.jackson.dataformat.cbor.CBORMapper"); + case XML -> classPresent("tools.jackson.dataformat.xml.XmlMapper"); + }; + } + + private static boolean classPresent(String className) { + try { + Class.forName(className, false, WebRepresentation.class.getClassLoader()); + return true; + } catch (ClassNotFoundException absent) { + return false; + } + } + + /** Resolve a media type. Empty for anything else, which the caller answers with a 406. */ + public static Optional fromMediaType(String mediaType) { + if (mediaType == null) { + return Optional.empty(); + } + String normalized = mediaType.trim().toLowerCase(Locale.ROOT); + int parameters = normalized.indexOf(';'); + String base = parameters < 0 ? normalized : normalized.substring(0, parameters).trim(); + for (WebRepresentation representation : values()) { + if (representation.mediaType.equals(base)) { + return Optional.of(representation); + } + } + return Optional.empty(); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/codec/WebXmlMapperFactory.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/codec/WebXmlMapperFactory.java new file mode 100644 index 00000000..ee36dbc9 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/codec/WebXmlMapperFactory.java @@ -0,0 +1,99 @@ +package dev.caskeleton.adapter.inbound.web.advanced.codec; + +import java.util.Objects; +import javax.xml.stream.XMLInputFactory; +import tools.jackson.core.StreamReadConstraints; +import tools.jackson.databind.DeserializationFeature; +import tools.jackson.databind.MapperFeature; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.dataformat.xml.XmlFactory; +import tools.jackson.dataformat.xml.XmlMapper; + +/** + * The XML mapper, built on an input factory that cannot read the filesystem. + * + *

The hardening is not applied here — it comes from {@link SecureXmlInputFactory}, and this + * factory refuses to build a mapper on anything else. That refusal is the point. Jackson's XML + * module will happily accept a default {@code XMLInputFactory}, and a default one resolves external + * entities and expands internal ones: XXE and the billion-laughs expansion respectively, neither of + * which produces an error when it fires. The parse succeeds and the resulting object holds the + * contents of a file, or the heap is gone. + * + *

Checking rather than only configuring matters because there are two ways to get a mapper: + * through this method, or through some other configuration path that builds its own factory. The + * second is the one that ships the vulnerability, and the check is what makes it fail loudly. + * + *

Strictness otherwise matches the JSON profile, for the reason the CBOR mapper does: a + * representation that accepts what the primary one rejects is a validation bypass reachable by + * changing one header. + */ +public final class WebXmlMapperFactory { + + private WebXmlMapperFactory() {} + + /** + * A mapper on a hardened input factory. + * + * @param budget supplies the decode bounds this representation runs under + */ + public static ObjectMapper create(CodecBudget budget) { + Objects.requireNonNull(budget, "budget"); + if (budget.representation() != WebRepresentation.XML) { + throw new IllegalArgumentException( + "an XML mapper built from another representation's budget would enforce the wrong" + + " limits: " + + budget.representation()); + } + requireBackend(); + return create(budget, SecureXmlInputFactory.create()); + } + + /** + * A mapper on a caller-supplied input factory, which must already be hardened. + * + * @throws IllegalArgumentException when the factory would resolve entities or read a DTD + */ + public static ObjectMapper create(CodecBudget budget, XMLInputFactory input) { + Objects.requireNonNull(budget, "budget"); + Objects.requireNonNull(input, "input"); + if (!SecureXmlInputFactory.secure(input)) { + // Refused rather than silently re-hardened. A caller that passed an unsafe factory has + // another code path that builds one, and quietly fixing this instance leaves that path + // shipping the vulnerability. + throw new IllegalArgumentException( + "the supplied XMLInputFactory resolves external entities or reads a DTD; build it with" + + " SecureXmlInputFactory.create() rather than hardening it here, so the path that" + + " produced this one is the thing that gets fixed"); + } + XmlFactory factory = + XmlFactory.builder() + .xmlInputFactory(input) + .streamReadConstraints( + StreamReadConstraints.builder() + .maxNestingDepth(budget.maxNestingDepth()) + .maxDocumentLength(budget.maxBodyBytes()) + .build()) + .build(); + return XmlMapper.builder(factory) + .enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .disable(MapperFeature.ALLOW_COERCION_OF_SCALARS) + .build(); + } + + /** + * Refuse with a sentence rather than a {@code NoClassDefFoundError} naming a Jackson class. + * + *

The backend is compile-only here for a reason specific to this format: putting + * jackson-dataformat-xml on the runtime classpath makes Spring register an XML message converter, + * so every deployment starts parsing {@code application/xml} request bodies whether or not it + * enabled the capability — an XXE surface acquired by adding a dependency. + */ + private static void requireBackend() { + if (!WebRepresentation.XML.available()) { + throw new IllegalStateException( + "the XML capability is enabled but tools.jackson.dataformat:jackson-dataformat-xml is not" + + " on the runtime classpath; it is compile-only here because its presence alone" + + " makes Spring accept application/xml request bodies everywhere"); + } + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/functional/FunctionalRoutePolicyValidator.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/functional/FunctionalRoutePolicyValidator.java new file mode 100644 index 00000000..7c0ab0e2 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/functional/FunctionalRoutePolicyValidator.java @@ -0,0 +1,53 @@ +package dev.caskeleton.adapter.inbound.web.advanced.functional; + +import dev.caskeleton.adapter.inbound.web.operation.WebOperationCatalog; +import dev.caskeleton.adapter.inbound.web.operation.WebOperationProfile; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** + * Checks that a functional route carries the same guarantees an annotated controller would. + * + *

The catalog lookup does most of the work — it throws for an unregistered operation — but two + * things it cannot check are checked here, because they are properties of the *route* rather than + * of the operation. + * + *

The method has to match. A catalog entry declares an operation as, say, a mutation with an + * idempotency policy; registering it behind a GET gives a read the write's policy and, worse, gives + * a write no read caching restrictions. Nothing downstream re-derives the method from the route. + */ +public final class FunctionalRoutePolicyValidator { + + private final WebOperationCatalog catalog; + + public FunctionalRoutePolicyValidator(WebOperationCatalog catalog) { + this.catalog = Objects.requireNonNull(catalog, "catalog"); + } + + /** + * Validate a route. + * + * @throws IllegalStateException listing everything wrong with it + */ + public void validate(RegisteredRoute route) { + Objects.requireNonNull(route, "route"); + // Throws for an unregistered operation, which is the check that matters most and the one a raw + // RouterFunction bean skips entirely. + WebOperationProfile profile = catalog.require(route.operationName()); + List faults = new ArrayList<>(); + if (profile.method() != route.method()) { + faults.add( + "the route is a " + + route.method() + + " but the operation is declared as a " + + profile.method() + + "; the profile's idempotency, precondition and cache policies were chosen for the " + + "declared method and nothing downstream re-derives it from the route"); + } + if (!faults.isEmpty()) { + throw new IllegalStateException( + "functional route " + route.routeKey() + " does not match its operation: " + faults); + } + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/functional/FunctionalRouteRegistry.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/functional/FunctionalRouteRegistry.java new file mode 100644 index 00000000..bffdbebb --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/functional/FunctionalRouteRegistry.java @@ -0,0 +1,60 @@ +package dev.caskeleton.adapter.inbound.web.advanced.functional; + +import dev.caskeleton.adapter.inbound.web.operation.WebOperationCatalog; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * The functional routes this deployment serves. + * + *

Every route goes through {@link #register}, and {@link #register} goes through the operation + * catalog. That is the whole design: a functional route that skipped this would be a {@code + * RouterFunction} bean, which Spring picks up and serves with no profile at all. + * + *

Duplicate detection is here rather than left to the router. Spring's {@code RouterFunctions} + * resolves the first match, so two registrations for the same method and pattern give one of them + * silently — and which one depends on registration order, which depends on bean order. + */ +public final class FunctionalRouteRegistry { + + private final FunctionalRoutePolicyValidator validator; + private final Map routes = new LinkedHashMap<>(); + + public FunctionalRouteRegistry(WebOperationCatalog catalog) { + this.validator = new FunctionalRoutePolicyValidator(Objects.requireNonNull(catalog, "catalog")); + } + + /** + * Register a route. + * + * @throws IllegalStateException if its operation is unregistered, its method disagrees with the + * operation, or the same method and pattern are already claimed + */ + public void register(RegisteredRoute route) { + Objects.requireNonNull(route, "route"); + validator.validate(route); + RegisteredRoute previous = routes.putIfAbsent(route.routeKey(), route); + if (previous != null) { + throw new IllegalStateException( + "two functional routes claim " + + route.routeKey() + + "; the router resolves the first match, so which one serves depends on bean " + + "ordering: " + + previous.handlerName() + + " and " + + route.handlerName()); + } + } + + /** Every registered route, in registration order. */ + public List routes() { + return List.copyOf(routes.values()); + } + + /** How many are registered. */ + public int size() { + return routes.size(); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/functional/RegisteredRoute.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/functional/RegisteredRoute.java new file mode 100644 index 00000000..bbc4cf9a --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/functional/RegisteredRoute.java @@ -0,0 +1,45 @@ +package dev.caskeleton.adapter.inbound.web.advanced.functional; + +import dev.caskeleton.adapter.inbound.web.core.WebOperationName; +import dev.caskeleton.adapter.inbound.web.operation.HttpMethodSemantic; +import java.util.Objects; + +/** + * One functional route, tied to a catalog operation. + * + *

The operation name is not optional and not derived. A functional route is a lambda registered + * against a path — there is no annotation for a scanner to find and no class name to infer from, so + * a route without a declared operation has no budget, no authorization profile, no idempotency + * policy and no cache policy, and nothing anywhere will say so. It will simply serve. + * + *

That is the entire difference between the annotated and functional styles as far as this leaf + * is concerned, and it is why {@code RouterFunction} beans are not registered directly. + * + * @param pattern the path pattern + * @param method the HTTP method + * @param operationName the catalog operation this route serves + * @param handlerName a stable name for logs and metrics + */ +public record RegisteredRoute( + String pattern, HttpMethodSemantic method, WebOperationName operationName, String handlerName) { + + public RegisteredRoute { + Objects.requireNonNull(pattern, "pattern"); + Objects.requireNonNull(method, "method"); + Objects.requireNonNull(operationName, "operationName"); + Objects.requireNonNull(handlerName, "handlerName"); + if (!pattern.startsWith("/")) { + throw new IllegalArgumentException("a route pattern is an absolute path: " + pattern); + } + if (handlerName.isBlank()) { + throw new IllegalArgumentException( + "a functional handler is a lambda, so it has no class name to fall back on in a log; " + + "the name has to be given"); + } + } + + /** The route's identity for duplicate detection. */ + public String routeKey() { + return method.name() + " " + pattern; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/functional/WebFunctionalHandlerAdapter.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/functional/WebFunctionalHandlerAdapter.java new file mode 100644 index 00000000..300ecb8d --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/functional/WebFunctionalHandlerAdapter.java @@ -0,0 +1,102 @@ +package dev.caskeleton.adapter.inbound.web.advanced.functional; + +import dev.caskeleton.adapter.inbound.web.operation.HttpMethodSemantic; +import java.util.List; +import java.util.Objects; +import java.util.function.Function; +import org.springframework.http.HttpMethod; +import org.springframework.web.reactive.function.server.HandlerFunction; +import org.springframework.web.reactive.function.server.RequestPredicate; +import org.springframework.web.reactive.function.server.RequestPredicates; +import org.springframework.web.reactive.function.server.RouterFunction; +import org.springframework.web.reactive.function.server.RouterFunctions; +import org.springframework.web.reactive.function.server.ServerResponse; + +/** + * Builds the {@link RouterFunction} from routes the registry has already validated. + * + *

Deliberately the only way a functional route reaches Spring in this leaf. A {@code + * RouterFunction} bean is picked up and served with no operation profile at all — no budget, no + * authorization profile, no idempotency policy — and nothing anywhere reports it. Building the + * router here, from the registry, means every route has been through {@link + * FunctionalRoutePolicyValidator} before it can be reachable. + * + *

Routes are composed in registration order and the registry has already refused duplicates. + * That ordering matters: {@code RouterFunctions} resolves the first match, so two routes for one + * method and pattern would make bean ordering decide which one serves. + */ +public final class WebFunctionalHandlerAdapter { + + private final FunctionalRouteRegistry registry; + private final Function> handlers; + + /** + * @param registry the validated routes + * @param handlers resolves a route to the handler that serves it + */ + public WebFunctionalHandlerAdapter( + FunctionalRouteRegistry registry, + Function> handlers) { + this.registry = Objects.requireNonNull(registry, "registry"); + this.handlers = Objects.requireNonNull(handlers, "handlers"); + } + + /** + * The composed router. + * + * @throws IllegalStateException when no route is registered, or a route has no handler + */ + public RouterFunction build() { + List routes = registry.routes(); + if (routes.isEmpty()) { + // Refused rather than returning an empty router. An empty one is a bean that serves nothing + // and looks installed, which is indistinguishable from a registry nobody populated. + throw new IllegalStateException( + "no functional route is registered; an empty router is a bean that serves nothing and" + + " looks installed"); + } + RouterFunction composed = null; + for (RegisteredRoute route : routes) { + RouterFunction single = + RouterFunctions.route(predicate(route), handler(route)); + composed = composed == null ? single : composed.and(single); + } + return composed; + } + + private HandlerFunction handler(RegisteredRoute route) { + HandlerFunction handler = handlers.apply(route); + if (handler == null) { + // A route with no handler would be a 404 that looks like a routing bug rather than a wiring + // one, and it would only appear when somebody called it. + throw new IllegalStateException( + "functional route " + + route.routeKey() + + " has no handler; '" + + route.handlerName() + + "' resolved to nothing"); + } + return handler; + } + + private static RequestPredicate predicate(RegisteredRoute route) { + return RequestPredicates.method(httpMethod(route.method())) + .and(RequestPredicates.path(route.pattern())); + } + + private static HttpMethod httpMethod(HttpMethodSemantic method) { + // An exhaustive switch rather than a name lookup or a map. The catalog's method vocabulary and + // Spring's are separate enums, so valueOf() across them turns adding a member to one into a + // runtime failure in the other — and a map with a computed default evaluates that default + // eagerly, which makes every lookup throw. + return switch (method) { + case GET -> HttpMethod.GET; + case HEAD -> HttpMethod.HEAD; + case OPTIONS -> HttpMethod.OPTIONS; + case POST -> HttpMethod.POST; + case PUT -> HttpMethod.PUT; + case PATCH -> HttpMethod.PATCH; + case DELETE -> HttpMethod.DELETE; + }; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/mvc/MvcDisconnectDetector.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/mvc/MvcDisconnectDetector.java new file mode 100644 index 00000000..ad29fda6 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/mvc/MvcDisconnectDetector.java @@ -0,0 +1,86 @@ +package dev.caskeleton.adapter.inbound.web.advanced.mvc; + +import dev.caskeleton.adapter.inbound.web.advanced.stream.WebStreamTermination; +import java.io.IOException; +import java.util.Objects; +import java.util.Optional; + +/** + * Decides what a failed write to a servlet stream means. + * + *

On the servlet side there is no disconnect event. The container does not tell the application + * that a client went away; the first the application knows is that a write throws. So the heartbeat + * is not a keepalive for the client's benefit — it is the *probe* that produces that throw on a + * stream that would otherwise sit silent for an hour holding a thread, a buffer and whatever its + * source is subscribed to. + * + *

Which is why a heartbeat write failure is recorded as disconnect evidence rather than as an + * error. It is the mechanism working. + * + *

The classification matters because the two cases go to different places. A client that closed + * its browser tab is {@link WebStreamTermination#CLIENT_DISCONNECTED} and is not a fault; anything + * else is {@link WebStreamTermination#ABRUPT_CLOSE} and is. + */ +public final class MvcDisconnectDetector { + + private MvcDisconnectDetector() {} + + /** + * Classify a write failure. + * + * @param failure what the write threw + */ + public static WebStreamTermination classify(Throwable failure) { + Objects.requireNonNull(failure, "failure"); + return clientWentAway(failure) + ? WebStreamTermination.CLIENT_DISCONNECTED + : WebStreamTermination.ABRUPT_CLOSE; + } + + /** + * Whether a failure means the client went away rather than something breaking. + * + *

Matched on the exception chain rather than on a message, with one exception: the containers + * differ on what they throw for a closed peer and several of them use a plain {@code IOException} + * whose only distinguishing feature is its text. Tomcat's is "Broken pipe", Jetty's is + * "EofException", Undertow closes the channel. Matching text is fragile and matching nothing is + * worse — every client disconnect would be logged as a server fault, and the fault rate would be + * whatever the tab-closing rate is. + */ + public static boolean clientWentAway(Throwable failure) { + for (Throwable current = failure; current != null; current = current.getCause()) { + if (current instanceof java.io.EOFException) { + return true; + } + if (current.getClass().getSimpleName().equals("EofException")) { + return true; + } + if (current instanceof IOException && brokenPipe(current.getMessage())) { + return true; + } + if (current.getCause() == current) { + break; + } + } + return false; + } + + /** The message a heartbeat failure should carry, if any. */ + public static Optional evidenceNote(Throwable failure) { + if (clientWentAway(failure)) { + return Optional.of("the heartbeat write failed because the client is gone"); + } + return Optional.empty(); + } + + private static boolean brokenPipe(String message) { + if (message == null) { + return false; + } + String lower = message.toLowerCase(java.util.Locale.ROOT); + return lower.contains("broken pipe") + || lower.contains("connection reset") + || lower.contains("connection was aborted") + || lower.contains("an established connection was aborted"); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/mvc/MvcStreamWriter.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/mvc/MvcStreamWriter.java new file mode 100644 index 00000000..479a4cb2 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/mvc/MvcStreamWriter.java @@ -0,0 +1,117 @@ +package dev.caskeleton.adapter.inbound.web.advanced.mvc; + +import dev.caskeleton.adapter.inbound.web.advanced.stream.StreamSequence; +import dev.caskeleton.adapter.inbound.web.advanced.stream.WebStreamEvidence; +import dev.caskeleton.adapter.inbound.web.advanced.stream.WebStreamTermination; +import dev.caskeleton.adapter.inbound.web.advanced.stream.encoding.NdjsonRecord; +import dev.caskeleton.adapter.inbound.web.advanced.stream.encoding.StreamFraming; +import dev.caskeleton.adapter.inbound.web.advanced.stream.encoding.StreamRecordEncoder; +import java.io.IOException; +import java.io.OutputStream; +import java.time.Instant; +import java.util.Iterator; +import java.util.Objects; + +/** + * Writes an NDJSON or JSON-seq response on the servlet stack. + * + *

One writer for both, parameterised by framing, because everything else about them is the same + * and the parts that matter are the parts that are the same: flush after every record, write the + * terminal record before closing, and classify a write failure rather than letting it become a 500 + * on a response that is already 200. + * + *

**Flushing after every record is not optional.** Without it the container buffers, and a + * streaming response that arrives in one chunk at the end is a slow non-streaming response — which + * passes every test that checks the body and fails the only thing the client wanted. + */ +public final class MvcStreamWriter { + + private final StreamRecordEncoder encoder; + private final StreamFraming framing; + + public MvcStreamWriter(StreamRecordEncoder encoder, StreamFraming framing) { + this.encoder = Objects.requireNonNull(encoder, "encoder"); + this.framing = Objects.requireNonNull(framing, "framing"); + } + + /** The media type to set on the response. */ + public String mediaType() { + return framing.mediaType(); + } + + /** + * Write a whole stream and its terminal record. + * + * @param records the source + * @param output the response body + * @param evidence what actually got written + * @return how the stream ended + */ + public WebStreamTermination write( + Iterator> records, OutputStream output, WebStreamEvidence evidence) { + Objects.requireNonNull(records, "records"); + Objects.requireNonNull(output, "output"); + Objects.requireNonNull(evidence, "evidence"); + long lastSequence = 0; + try { + while (records.hasNext()) { + NdjsonRecord record = records.next(); + writeRecord(record, output); + if (record instanceof NdjsonRecord.Item item) { + evidence.recordDelivered(new StreamSequence(item.sequence())); + lastSequence = item.sequence(); + } + } + writeRecord(NdjsonRecord.complete(lastSequence), output); + evidence.recordTermination(WebStreamTermination.NORMAL_COMPLETE, Instant.now()); + return WebStreamTermination.NORMAL_COMPLETE; + } catch (IOException failure) { + WebStreamTermination how = MvcDisconnectDetector.classify(failure); + evidence.recordTermination(how, Instant.now()); + return how; + } catch (RuntimeException failure) { + return terminate(failure, output, evidence, lastSequence); + } + } + + private WebStreamTermination terminate( + RuntimeException failure, + OutputStream output, + WebStreamEvidence evidence, + long lastSequence) { + // A client disconnect can reach here wrapped in a RuntimeException — the containers are not + // consistent about which layer wraps it. Writing a terminal record to a socket that is already + // gone would then be counted as a server fault, and the fault rate would track the + // tab-closing rate. + if (MvcDisconnectDetector.clientWentAway(failure)) { + evidence.recordTermination(WebStreamTermination.CLIENT_DISCONNECTED, Instant.now()); + return WebStreamTermination.CLIENT_DISCONNECTED; + } + // Otherwise the source failed after commit. The status is already 200, so the failure goes + // into the stream as a terminal record — and if even that cannot be written, it is an abrupt + // close and is recorded as one rather than counted as a completion. + try { + writeRecord( + new NdjsonRecord.Failure( + lastSequence, + "DEPENDENCY_FAILURE", + "the stream ended early because a dependency failed"), + output); + evidence.recordTermination(WebStreamTermination.TERMINAL_ERROR_RECORD, Instant.now()); + return WebStreamTermination.TERMINAL_ERROR_RECORD; + } catch (IOException | RuntimeException unwritable) { + Objects.requireNonNull(unwritable); + evidence.recordTermination(WebStreamTermination.ABRUPT_CLOSE, Instant.now()); + return WebStreamTermination.ABRUPT_CLOSE; + } + } + + private void writeRecord(Object record, OutputStream output) throws IOException { + byte[] bytes = + framing == StreamFraming.NDJSON ? encoder.ndjson(record) : encoder.jsonSequence(record); + output.write(bytes); + // Per record. A buffered streaming response arrives in one chunk at the end, which passes a + // body assertion and fails the only property the client wanted. + output.flush(); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/mvc/MvcStreamingExecutorConfiguration.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/mvc/MvcStreamingExecutorConfiguration.java new file mode 100644 index 00000000..5e11dd08 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/mvc/MvcStreamingExecutorConfiguration.java @@ -0,0 +1,86 @@ +package dev.caskeleton.adapter.inbound.web.advanced.mvc; + +import dev.caskeleton.adapter.inbound.web.advanced.stream.WebStreamPolicy; +import dev.caskeleton.adapter.inbound.web.advanced.stream.WebStreamRegistry; +import dev.caskeleton.adapter.inbound.web.advanced.stream.encoding.StreamFraming; +import dev.caskeleton.adapter.inbound.web.advanced.stream.encoding.StreamRecordEncoder; +import dev.caskeleton.adapter.inbound.web.json.WebObjectMapperFactory; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Wires servlet-side record streaming: NDJSON and RFC 7464 JSON text sequences. + * + *

No server-sent events here, and not by oversight. This repository's {@code + * feature-streaming-response-contract} D3 refuses SSE on the servlet stack, and {@code + * CleanArchitectureTest.NO_SSE_EMITTER} / {@code NO_RESPONSE_BODY_EMITTER} enforce it: production + * code may not name {@code SseEmitter} or {@code ResponseBodyEmitter}. The same decision explicitly + * permits {@code StreamingResponseBody}, which is the shape {@link MvcStreamWriter} writes — a + * response body produced incrementally, in the request-response model, rather than a server-push + * channel. + * + *

The design package this leaf implements asks for an MVC SSE adapter. That request loses to the + * repository's own recorded decision, which is what {@code AGENTS.md} says happens when the two + * disagree. The reactive stack keeps its SSE adapter, where no such rule applies. + * + *

What survives from the SSE design is the part that was never SSE-specific: a bounded registry + * of open streams, a per-item encoder that enforces the size ceiling before framing, and a + * disconnect classifier — because on the servlet stack a failed write is still the only signal that + * a client has gone. + */ +@Configuration(proxyBeanMethods = false) +@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) +@ConditionalOnProperty( + prefix = "backend.web.advanced.ndjson", + name = "enabled", + havingValue = "true") +public class MvcStreamingExecutorConfiguration { + + /** The bounds every stream on this node runs under. */ + @Bean + @ConditionalOnMissingBean + public WebStreamPolicy webStreamPolicy() { + return WebStreamPolicy.conventional(); + } + + /** + * The node's stream ceiling. + * + *

Sized independently of the request thread pool: a stream is a request that never finishes, + * so the pool bounds arrivals and this bounds residents. + */ + @Bean + @ConditionalOnMissingBean + public WebStreamRegistry webStreamRegistry() { + return new WebStreamRegistry(1_000); + } + + /** Serializes stream records, and enforces the per-item ceiling before framing. */ + @Bean + @ConditionalOnMissingBean + public StreamRecordEncoder streamRecordEncoder(WebStreamPolicy policy) { + return new StreamRecordEncoder(WebObjectMapperFactory.standard(), policy); + } + + /** The NDJSON writer. */ + @Bean + @ConditionalOnMissingBean(name = "mvcNdjsonWriter") + public MvcStreamWriter mvcNdjsonWriter(StreamRecordEncoder encoder) { + return new MvcStreamWriter(encoder, StreamFraming.NDJSON); + } + + /** + * The JSON text sequence writer. + * + *

A separate bean rather than a parameter on one, because the framing is a property of the + * route's media type and a route wires the writer that matches what it declares it produces. + */ + @Bean + @ConditionalOnMissingBean(name = "mvcJsonSequenceWriter") + public MvcStreamWriter mvcJsonSequenceWriter(StreamRecordEncoder encoder) { + return new MvcStreamWriter(encoder, StreamFraming.JSON_SEQUENCE); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/mvc/VirtualThreadMvcConfiguration.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/mvc/VirtualThreadMvcConfiguration.java new file mode 100644 index 00000000..9bec0eae --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/mvc/VirtualThreadMvcConfiguration.java @@ -0,0 +1,77 @@ +package dev.caskeleton.adapter.inbound.web.advanced.mvc; + +import dev.caskeleton.adapter.inbound.web.advanced.virtualthread.VirtualThreadAdmissionGuard; +import dev.caskeleton.adapter.inbound.web.advanced.virtualthread.VirtualThreadProfile; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.task.AsyncTaskExecutor; +import org.springframework.core.task.TaskDecorator; +import org.springframework.core.task.support.TaskExecutorAdapter; + +/** + * Wires the virtual-thread executor and, in the same configuration, the admission limit. + * + *

The two are one bean definition set on purpose. A deployment that got the executor without the + * limit would have deleted its implicit concurrency bound and replaced it with nothing — and it + * would look fine until the arrival rate exceeded what the database pool serves, at which point + * every request times out having done no work. + * + *

{@link VirtualThreadSettings} refuses to bind an enabled profile without a limit, so that + * combination cannot be configured. This configuration then cannot produce the executor without + * also producing the guard, because both are in it. + */ +@Configuration(proxyBeanMethods = false) +@EnableConfigurationProperties(VirtualThreadSettings.class) +@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) +@ConditionalOnProperty( + prefix = "backend.web.advanced.mvc-virtual-threads", + name = "enabled", + havingValue = "true") +public class VirtualThreadMvcConfiguration { + + /** The validated profile, for the startup report and for anything that needs the budgets. */ + @Bean + public VirtualThreadProfile virtualThreadProfile(VirtualThreadSettings properties) { + return properties.toProfile(); + } + + /** + * The concurrency limit the thread pool used to provide. + * + *

A fair semaphore over use cases, not a pool over threads. Bounding the threads would put the + * waiting back and throw away what virtual threads bought. + */ + @Bean + @ConditionalOnMissingBean + public VirtualThreadAdmissionGuard virtualThreadAdmissionGuard(VirtualThreadSettings properties) { + return new VirtualThreadAdmissionGuard( + properties.getAdmissionLimit(), properties.getAdmissionWait()); + } + + /** + * The executor Spring MVC dispatches async request handling onto. + * + *

Named {@code applicationTaskExecutor} because that is the bean Spring Boot's MVC async + * support looks for. A differently named bean is created, is never used, and leaves the container + * default in place — which is the failure mode where the whole capability is switched on and + * nothing changes. + */ + @Bean("applicationTaskExecutor") + @ConditionalOnMissingBean(name = "applicationTaskExecutor") + public AsyncTaskExecutor webMvcVirtualThreadExecutor(TaskDecorator contextDecorator) { + Executor delegate = + Executors.newThreadPerTaskExecutor(Thread.ofVirtual().name("web-mvc-vt-", 0).factory()); + TaskExecutorAdapter executor = new TaskExecutorAdapter(delegate); + // Without it every async hop loses request_id, trace_id, correlation_id and tenant_id — and a + // virtual-thread executor makes that worse rather than better, because the carrier thread a + // task lands on has no relationship to the one that submitted it. + executor.setTaskDecorator(contextDecorator); + return executor; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/mvc/VirtualThreadSettings.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/mvc/VirtualThreadSettings.java new file mode 100644 index 00000000..e53c1995 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/mvc/VirtualThreadSettings.java @@ -0,0 +1,103 @@ +package dev.caskeleton.adapter.inbound.web.advanced.mvc; + +import dev.caskeleton.adapter.inbound.web.advanced.virtualthread.VirtualThreadProfile; +import jakarta.validation.constraints.AssertTrue; +import java.time.Duration; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + +/** + * Settings bound from {@code backend.web.advanced.mvc-virtual-threads.*}. + * + *

The downstream budgets are settings rather than something read from the pool at runtime, + * deliberately. Reading them would make the check pass automatically — whatever the pool is, the + * admission limit would be compared against it and the operator would never be asked to think about + * the relationship. Writing them down here is what makes a mismatch visible in a diff. + */ +@ConfigurationProperties(prefix = "backend.web.advanced.mvc-virtual-threads") +@Validated +public class VirtualThreadSettings { + + /** Whether request handling runs on virtual threads. Activation must be explicit. */ + private boolean enabled; + + /** How many use cases may run at once. Not a thread count. */ + private int admissionLimit; + + /** The database connection pool, restated so a mismatch is visible. */ + private int databasePoolSize; + + /** The outbound HTTP bulkhead, restated for the same reason. */ + private int outboundBulkhead; + + /** How long an arrival waits for a permit before it is refused. */ + private Duration admissionWait = Duration.ofMillis(250); + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public int getAdmissionLimit() { + return admissionLimit; + } + + public void setAdmissionLimit(int admissionLimit) { + this.admissionLimit = admissionLimit; + } + + public int getDatabasePoolSize() { + return databasePoolSize; + } + + public void setDatabasePoolSize(int databasePoolSize) { + this.databasePoolSize = databasePoolSize; + } + + public int getOutboundBulkhead() { + return outboundBulkhead; + } + + public void setOutboundBulkhead(int outboundBulkhead) { + this.outboundBulkhead = outboundBulkhead; + } + + public Duration getAdmissionWait() { + return admissionWait; + } + + public void setAdmissionWait(Duration admissionWait) { + this.admissionWait = admissionWait; + } + + /** The validated profile. */ + public VirtualThreadProfile toProfile() { + return new VirtualThreadProfile(enabled, admissionLimit, databasePoolSize, outboundBulkhead); + } + + @AssertTrue( + message = + "enabling virtual threads requires an admission limit and the downstream budgets it must" + + " not exceed; the thread pool was the admission policy, and removing it without" + + " replacing it accepts every arrival and queues them on budgets that did not grow") + public boolean isProfileValid() { + if (!enabled) { + return true; + } + try { + toProfile(); + return true; + } catch (IllegalArgumentException refused) { + return false; + } + } + + @AssertTrue(message = "the admission wait must be positive") + public boolean isAdmissionWaitValid() { + return !enabled + || (admissionWait != null && !admissionWait.isNegative() && !admissionWait.isZero()); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/openapi/OpenApi32CompatibilityReport.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/openapi/OpenApi32CompatibilityReport.java new file mode 100644 index 00000000..5b81c84b --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/openapi/OpenApi32CompatibilityReport.java @@ -0,0 +1,72 @@ +package dev.caskeleton.adapter.inbound.web.advanced.openapi; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** + * What differs between the Stable 3.1 document and the experimental 3.2 one, and whether 3.2 may be + * promoted. + * + *

The invariant that matters more than any difference: generating 3.2 must not change the 3.1 + * snapshot. They are produced from the same model, so a contributor that mutates the shared model + * on the way to 3.2 changes the artifact that is actually shipped — and the 3.1 snapshot check + * would catch it only if somebody thought to run it in the same build. + * + * @param stableSnapshotHash the 3.1 snapshot's hash before generation + * @param stableSnapshotHashAfter its hash afterwards + * @param streamingDescriptionDifferences where the two documents describe streaming differently + * @param matrix which tools have been shown to read the 3.2 output + */ +public record OpenApi32CompatibilityReport( + String stableSnapshotHash, + String stableSnapshotHashAfter, + List streamingDescriptionDifferences, + OpenApiToolchainMatrix matrix) { + + public OpenApi32CompatibilityReport { + Objects.requireNonNull(stableSnapshotHash, "stableSnapshotHash"); + Objects.requireNonNull(stableSnapshotHashAfter, "stableSnapshotHashAfter"); + streamingDescriptionDifferences = + List.copyOf( + Objects.requireNonNull( + streamingDescriptionDifferences, "streamingDescriptionDifferences")); + Objects.requireNonNull(matrix, "matrix"); + } + + /** Whether generating 3.2 left the shipped artifact alone. */ + public boolean stableArtifactUnchanged() { + return stableSnapshotHash.equals(stableSnapshotHashAfter); + } + + /** + * Why 3.2 may not be promoted yet. + * + *

A list rather than a boolean, and it is never empty of its own accord: promotion also + * requires an ADR, which is not a machine-checkable condition and is therefore stated here as a + * blocker that a human removes. + * + * @param adrAccepted whether the promotion ADR has been accepted + */ + public List promotionBlockers(boolean adrAccepted) { + List blockers = new ArrayList<>(); + if (!stableArtifactUnchanged()) { + blockers.add( + "generating 3.2 changed the 3.1 snapshot, so the two share mutable state and the shipped " + + "artifact depends on whether the experimental lane ran"); + } + if (!matrix.complete()) { + blockers.addAll(matrix.gaps()); + } + if (!adrAccepted) { + blockers.add( + "no accepted promotion ADR; 3.2 stays experimental regardless of how green the matrix is"); + } + return List.copyOf(blockers); + } + + /** Whether 3.2 may become the release artifact. */ + public boolean promotable(boolean adrAccepted) { + return promotionBlockers(adrAccepted).isEmpty(); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/openapi/OpenApiDeepCopy.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/openapi/OpenApiDeepCopy.java new file mode 100644 index 00000000..b5f2614e --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/openapi/OpenApiDeepCopy.java @@ -0,0 +1,40 @@ +package dev.caskeleton.adapter.inbound.web.advanced.openapi; + +import io.swagger.v3.core.util.Json31; +import io.swagger.v3.oas.models.OpenAPI; +import java.util.Objects; + +/** + * Copies an OpenAPI model by serialising and reading it back. + * + *

Through the document's own JSON rather than by walking the object graph, and the reason is + * that walking it is unmaintainable in the exact way that matters here. The model has dozens of + * mutable node types with nested maps and lists; a hand-written copy is correct only until swagger + * adds a field, and a missed field is a shared mutable node — which is the one failure this copy + * exists to prevent, appearing later and silently. + * + *

Serialising is slower and completely faithful. This runs once per build of the experimental + * document, so the cost is irrelevant and the faithfulness is not. + */ +final class OpenApiDeepCopy { + + private OpenApiDeepCopy() {} + + /** + * A copy that shares no mutable node with the original. + * + * @param source the document to copy + */ + static OpenAPI of(OpenAPI source) { + Objects.requireNonNull(source, "source"); + try { + return Json31.mapper().readValue(Json31.mapper().writeValueAsString(source), OpenAPI.class); + } catch (Exception failure) { + // Not recoverable and not maskable. A copy that silently returned the original would let the + // experimental lane mutate the shipped artifact, which is precisely what this prevents. + throw new IllegalStateException( + "the Stable OpenAPI model could not be copied, so generating 3.2 would have to share it", + failure); + } + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/openapi/OpenApiToolchainMatrix.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/openapi/OpenApiToolchainMatrix.java new file mode 100644 index 00000000..72ae00a5 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/openapi/OpenApiToolchainMatrix.java @@ -0,0 +1,81 @@ +package dev.caskeleton.adapter.inbound.web.advanced.openapi; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * Which tools have been shown to read a generated document. + * + *

Four distinct kinds of tool, because they fail differently and passing one says nothing about + * the others. A parser reads the document and reports errors. A linter applies style rules and will + * happily accept a document a parser rejects. A generator produces client code, and this is where + * an unsupported construct usually surfaces — not as an error but as a generated method with the + * wrong signature. A compile check on that generated code is the only step that catches it. + * + *

"OpenAPI 3.2 works" is therefore not a statement anybody can make. "This document is read + * correctly by these four tools at these versions" is. + */ +public final class OpenApiToolchainMatrix { + + private final Map> results = new LinkedHashMap<>(); + + /** Record a result. */ + public OpenApiToolchainMatrix record(ToolKind kind, String toolVersion, boolean passed) { + Objects.requireNonNull(kind, "kind"); + Objects.requireNonNull(toolVersion, "toolVersion"); + results.computeIfAbsent(kind, key -> new LinkedHashMap<>()).put(toolVersion, passed); + return this; + } + + /** Whether every kind has at least one passing tool. */ + public boolean complete() { + for (ToolKind kind : ToolKind.values()) { + Map forKind = results.get(kind); + if (forKind == null || forKind.values().stream().noneMatch(Boolean::booleanValue)) { + return false; + } + } + return true; + } + + /** What is missing, for the compatibility report. */ + public List gaps() { + return java.util.Arrays.stream(ToolKind.values()) + .filter( + kind -> { + Map forKind = results.get(kind); + return forKind == null || forKind.values().stream().noneMatch(Boolean::booleanValue); + }) + .map(kind -> kind.name() + ": no passing tool recorded") + .toList(); + } + + /** Everything recorded, for the report. */ + public Map> results() { + Map> copy = new LinkedHashMap<>(); + results.forEach((kind, byVersion) -> copy.put(kind, Map.copyOf(byVersion))); + return Map.copyOf(copy); + } + + /** The kinds of tool that have to be checked separately. */ + public enum ToolKind { + + /** Reads the document and reports structural errors. */ + PARSER, + + /** Applies style rules; accepts documents a parser rejects. */ + LINTER, + + /** Produces client code. Where unsupported constructs usually surface. */ + GENERATOR, + + /** + * Compiles the generated client. + * + *

The only step that catches a generator emitting a wrong-but-syntactically-valid signature. + */ + CLIENT_COMPILE + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/openapi/OpenApiVersionLane.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/openapi/OpenApiVersionLane.java new file mode 100644 index 00000000..39ee8a7d --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/openapi/OpenApiVersionLane.java @@ -0,0 +1,36 @@ +package dev.caskeleton.adapter.inbound.web.advanced.openapi; + +/** + * Which OpenAPI version a generated document targets. + * + *

Two lanes, and only one of them is the release artifact. The distinction exists because + * generating 3.2 is cheap and *adopting* it is not: the value of an API description is entirely in + * what consumes it, and a document in a version that a client generator does not understand is + * worse than no document — it produces a client that compiles and is wrong. + */ +public enum OpenApiVersionLane { + + /** OpenAPI 3.1.2. The release artifact and the source of truth. */ + STABLE_3_1("3.1.2", true), + + /** OpenAPI 3.2.0. Generated in parallel, promoted only by an ADR. */ + EXPERIMENTAL_3_2("3.2.0", false); + + private final String version; + private final boolean releaseArtifact; + + OpenApiVersionLane(String version, boolean releaseArtifact) { + this.version = version; + this.releaseArtifact = releaseArtifact; + } + + /** The version string that goes in the document. */ + public String version() { + return version; + } + + /** Whether this lane's output is the thing shipped to consumers. */ + public boolean releaseArtifact() { + return releaseArtifact; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/openapi/WebOpenApi32Generator.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/openapi/WebOpenApi32Generator.java new file mode 100644 index 00000000..ea237116 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/openapi/WebOpenApi32Generator.java @@ -0,0 +1,122 @@ +package dev.caskeleton.adapter.inbound.web.advanced.openapi; + +import dev.caskeleton.adapter.inbound.web.advanced.stream.encoding.StreamMediaType; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.Operation; +import io.swagger.v3.oas.models.PathItem; +import io.swagger.v3.oas.models.media.Content; +import io.swagger.v3.oas.models.media.MediaType; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +/** + * Produces the experimental OpenAPI 3.2 document beside the Stable 3.1 one. + * + *

The generator works on a deep copy of the Stable model, and that is the whole + * reason this class exists rather than a two-line customizer. Both documents come from the same + * in-memory model, so a contributor that mutates it on the way to 3.2 changes the artifact that is + * actually shipped — silently, and only in builds where the experimental lane happened to run. + * Copying is the only thing that makes the two independent; the snapshot-hash check in {@link + * OpenApi32CompatibilityReport} is what proves the copy held. + * + *

What 3.2 adds for this application is streaming description. 3.1 can say a response is {@code + * text/event-stream} and cannot say what one event looks like, so a generator produces a client + * that treats the whole stream as one body. 3.2's {@code itemSchema} says what each item is. That + * difference is reported rather than applied silently, because it changes what a generated client + * does. + */ +public final class WebOpenApi32Generator { + + /** The media types whose per-item schema 3.2 can express and 3.1 cannot. */ + private static final Set STREAMING_MEDIA_TYPES = + Set.of(StreamMediaType.SSE, StreamMediaType.NDJSON, StreamMediaType.JSON_SEQ); + + private final List streamingDifferences = new ArrayList<>(); + + /** + * Generate the 3.2 document. + * + * @param stable the Stable model, which is not modified + * @return a separate document at version 3.2.0 + */ + public OpenAPI generate(OpenAPI stable) { + Objects.requireNonNull(stable, "stable"); + streamingDifferences.clear(); + // A copy, not the model. See the class comment: sharing it makes the shipped artifact depend on + // whether this lane ran. + OpenAPI experimental = OpenApiDeepCopy.of(stable); + experimental.setOpenapi(OpenApiVersionLane.EXPERIMENTAL_3_2.version()); + describeStreaming(experimental); + return experimental; + } + + /** + * Where the two documents describe streaming differently. + * + *

Valid after {@link #generate}. Reported separately rather than folded into a pass/fail, + * because a green pass hides what changed and the change is the point. + */ + public List streamingDifferences() { + return List.copyOf(streamingDifferences); + } + + private void describeStreaming(OpenAPI document) { + if (document.getPaths() == null) { + return; + } + document + .getPaths() + .forEach( + (path, item) -> + operationsOf(item) + .forEach(operation -> describeStreamingResponses(path, operation))); + } + + private void describeStreamingResponses(String path, Operation operation) { + if (operation.getResponses() == null) { + return; + } + operation + .getResponses() + .forEach( + (status, response) -> { + Content content = response.getContent(); + if (content == null) { + return; + } + content.forEach( + (mediaType, media) -> { + if (STREAMING_MEDIA_TYPES.contains(mediaType)) { + applyItemSchema(path, status, mediaType, media); + } + }); + }); + } + + private void applyItemSchema(String path, String status, String mediaType, MediaType media) { + if (media.getSchema() == null) { + // Nothing to lift. A streaming response with no schema is under-described in both versions, + // and inventing one here would make 3.2 look better than the source model actually is. + return; + } + // 3.2 carries the per-item schema in `itemSchema`; the swagger-models version on this + // classpath is a 3.1 model, so the field is added as an extension rather than as a typed + // property. That is honest about what this build can express and keeps the document valid. + media.addExtension("x-itemSchema", media.getSchema()); + streamingDifferences.add( + path + + " " + + status + + " " + + mediaType + + ": 3.2 describes the per-item schema, 3.1 describes only the stream body — a" + + " generator reading the 3.1 document produces a client that treats the whole stream" + + " as one response"); + } + + private static List operationsOf(PathItem item) { + return item.readOperations(); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/patch/JsonMergePatchApplier.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/patch/JsonMergePatchApplier.java new file mode 100644 index 00000000..2ef9b377 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/patch/JsonMergePatchApplier.java @@ -0,0 +1,87 @@ +package dev.caskeleton.adapter.inbound.web.advanced.patch; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.node.ObjectNode; + +/** + * Applies an RFC 7396 merge patch to a typed target. + * + *

Via a tree rather than by mutating the object, because a merge patch's defining behaviour — + * {@code null} means delete, absent means leave alone — has no representation in a Java object. A + * DTO deserialized from a partial patch cannot distinguish a field the caller set to null from one + * they did not mention: both are a null field. That single ambiguity is the whole reason merge + * patch implementations get written against a tree. + * + *

The result is re-validated as a whole document. A patch that is individually valid can produce + * an object that is not — two fields that must agree, a state that only some transitions allow — + * and validating only the changed fields sees none of it. + */ +public final class JsonMergePatchApplier { + + private final ObjectMapper mapper; + private final PatchFieldAuthorization authorization; + + /** + * @param mapper the strict mapper the rest of the leaf uses + * @param authorization which fields may be modified + */ + public JsonMergePatchApplier(ObjectMapper mapper, PatchFieldAuthorization authorization) { + this.mapper = Objects.requireNonNull(mapper, "mapper"); + this.authorization = Objects.requireNonNull(authorization, "authorization"); + } + + /** + * Apply a patch. + * + * @param current the value as it is now + * @param patch the patch + * @param type the target type + * @param the target type + */ + public PatchResult apply(T current, JsonMergePatchDocument patch, Class type) { + Objects.requireNonNull(current, "current"); + Objects.requireNonNull(patch, "patch"); + Objects.requireNonNull(type, "type"); + // Authorization first, before anything is merged. Checking afterwards would mean deciding + // whether a refused field "actually changed anything", and a caller who can ask that question + // can use it to probe values they may not read. + authorization.validate(patch.fieldNames()); + JsonNode before = mapper.valueToTree(current); + if (!before.isObject()) { + throw new IllegalArgumentException("a merge patch target must serialize to a JSON object"); + } + ObjectNode after = merge((ObjectNode) before.deepCopy(), patch.root()); + List modified = new ArrayList<>(); + for (String field : patch.fieldNames()) { + JsonNode was = before.get(field); + JsonNode now = after.get(field); + if (!Objects.equals(was, now)) { + modified.add(field); + } + } + return new PatchResult<>(mapper.treeToValue(after, type), modified); + } + + private static ObjectNode merge(ObjectNode target, ObjectNode patch) { + for (String field : patch.propertyNames()) { + JsonNode value = patch.get(field); + if (value.isNull()) { + // RFC 7396: null removes the member. Not "sets it to null" — the distinction matters for a + // target whose field is itself nullable. + target.remove(field); + } else if (value.isObject() && target.get(field) != null && target.get(field).isObject()) { + merge((ObjectNode) target.get(field), (ObjectNode) value); + } else { + // Arrays are replaced whole, never merged element-wise. RFC 7396 is explicit, and the + // alternative has no sensible definition: there is no way to say "change element 3" in a + // merge patch. + target.set(field, value); + } + } + return target; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/patch/JsonMergePatchDocument.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/patch/JsonMergePatchDocument.java new file mode 100644 index 00000000..33388538 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/patch/JsonMergePatchDocument.java @@ -0,0 +1,81 @@ +package dev.caskeleton.adapter.inbound.web.advanced.patch; + +import java.util.List; +import java.util.Objects; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.node.ObjectNode; + +/** + * A parsed RFC 7396 merge patch. + * + *

Parsed once and carried as a value, so the depth and size checks happen at the boundary rather + * than inside the merge loop. A merge patch nests arbitrarily and the merge is recursive; without a + * depth bound, a body of ten thousand nested objects is a stack overflow, and a stack overflow + * inside a request thread is not something the error handler can turn into a 400. + * + * @param root the patch document + */ +public record JsonMergePatchDocument(ObjectNode root) { + + /** Deep enough for any real document, shallow enough that recursion cannot exhaust the stack. */ + public static final int MAX_DEPTH = 32; + + public JsonMergePatchDocument { + Objects.requireNonNull(root, "root"); + int depth = depthOf(root, 1); + if (depth > MAX_DEPTH) { + throw new IllegalArgumentException( + "a merge patch nested " + + depth + + " deep exceeds the " + + MAX_DEPTH + + " limit; the merge is recursive, and a stack overflow inside a request thread is " + + "not something an exception handler can turn into a 400"); + } + } + + /** + * Parse a body. + * + * @param mapper the strict mapper + * @param body the request body + * @throws IllegalArgumentException if the body is not a JSON object + */ + public static JsonMergePatchDocument parse(ObjectMapper mapper, byte[] body) { + Objects.requireNonNull(mapper, "mapper"); + Objects.requireNonNull(body, "body"); + JsonNode parsed = mapper.readTree(body); + if (!parsed.isObject()) { + // RFC 7396 permits a scalar or an array as a whole-document replacement. Refused here: a + // merge patch that replaces the entire resource is a PUT, and routing it through the patch + // path skips the full-document validation a PUT gets. + throw new IllegalArgumentException( + "a merge patch must be a JSON object; a scalar or array patch replaces the whole " + + "resource, which is a PUT and belongs on the PUT route with its validation"); + } + return new JsonMergePatchDocument((ObjectNode) parsed); + } + + /** The top-level field names the patch touches. */ + public List fieldNames() { + return root.propertyNames().stream().sorted().toList(); + } + + /** Whether the patch deletes a field by setting it to null. */ + public boolean deletes(String field) { + JsonNode value = root.get(field); + return value != null && value.isNull(); + } + + private static int depthOf(JsonNode node, int current) { + if (!node.isObject()) { + return current; + } + int deepest = current; + for (JsonNode child : node.values()) { + deepest = Math.max(deepest, depthOf(child, current + 1)); + } + return deepest; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/patch/JsonPatchApplier.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/patch/JsonPatchApplier.java new file mode 100644 index 00000000..fb2af9a0 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/patch/JsonPatchApplier.java @@ -0,0 +1,190 @@ +package dev.caskeleton.adapter.inbound.web.advanced.patch; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.node.ArrayNode; +import tools.jackson.databind.node.ObjectNode; + +/** + * Applies an RFC 6902 patch atomically. + * + *

Atomic in the only way that matters here: every operation runs against a working copy, and the + * caller's object is never touched until all of them have succeeded and the result has passed + * validation. A patch that fails at operation seven leaves the caller with exactly what they had. + * + *

That is not an optimisation. RFC 6902 requires it — "if a normative requirement is violated, + * the entire patch document MUST NOT be applied" — and it is the property {@code test} exists to + * exploit. A client putting a {@code test} first is relying on nothing after it having happened + * when the test fails. + * + *

Authorization runs before the first operation too, and for the same reason a merge patch's + * does: deciding afterwards whether a refused operation "actually changed anything" is a question + * whose answer leaks the value. + */ +public final class JsonPatchApplier { + + private final ObjectMapper mapper; + private final JsonPointerAuthorization authorization; + + /** + * @param mapper the strict mapper the rest of the leaf uses + * @param authorization which pointers may be modified + */ + public JsonPatchApplier(ObjectMapper mapper, JsonPointerAuthorization authorization) { + this.mapper = Objects.requireNonNull(mapper, "mapper"); + this.authorization = Objects.requireNonNull(authorization, "authorization"); + } + + /** + * Apply a document. + * + * @param current the value as it is now, which is not modified + * @param patch the operations + * @param type the target type + * @param the target type + */ + public PatchResult apply(T current, JsonPatchDocument patch, Class type) { + Objects.requireNonNull(current, "current"); + Objects.requireNonNull(patch, "patch"); + Objects.requireNonNull(type, "type"); + authorization.validate(patch.operations()); + JsonNode before = mapper.valueToTree(current); + JsonNode working = before.deepCopy(); + List touched = new ArrayList<>(); + for (JsonPatchOperation operation : patch.operations()) { + working = applyOne(working, operation); + if (operation.kind().mutating()) { + touched.add(operation.path()); + } + } + if (working.equals(before)) { + return new PatchResult<>(mapper.treeToValue(working, type), List.of()); + } + return new PatchResult<>(mapper.treeToValue(working, type), touched); + } + + private JsonNode applyOne(JsonNode root, JsonPatchOperation operation) { + return switch (operation.kind()) { + case TEST -> { + JsonNode found = at(root, operation.path()); + if (found == null || !found.equals(operation.value().orElseThrow())) { + throw new JsonPatchTestFailedException(operation.path()); + } + yield root; + } + case ADD, REPLACE -> { + if (operation.kind() == JsonPatchOperationKind.REPLACE + && at(root, operation.path()) == null) { + // RFC 6902: replace requires the target to exist. Treating it as an add turns a client's + // "change this" into "create this", which is how a field the resource never had appears. + throw new JsonPatchRejectedException( + "replace requires the target location to exist: " + operation.path()); + } + yield set(root, operation.path(), operation.value().orElseThrow()); + } + case REMOVE -> { + if (at(root, operation.path()) == null) { + throw new JsonPatchRejectedException( + "remove requires the target location to exist: " + operation.path()); + } + yield remove(root, operation.path()); + } + case COPY -> { + JsonNode source = required(root, operation.from().orElseThrow()); + yield set(root, operation.path(), source.deepCopy()); + } + case MOVE -> { + String from = operation.from().orElseThrow(); + JsonNode source = required(root, from).deepCopy(); + yield set(remove(root, from), operation.path(), source); + } + }; + } + + private JsonNode required(JsonNode root, String pointer) { + JsonNode found = at(root, pointer); + if (found == null) { + throw new JsonPatchRejectedException("source location does not exist: " + pointer); + } + return found; + } + + private static JsonNode at(JsonNode root, String pointer) { + JsonNode found = root.at(pointer); + return found.isMissingNode() ? null : found; + } + + private JsonNode set(JsonNode root, String pointer, JsonNode value) { + if (pointer.isEmpty()) { + return value; + } + int lastSlash = pointer.lastIndexOf('/'); + String parentPointer = pointer.substring(0, lastSlash); + String key = unescape(pointer.substring(lastSlash + 1)); + JsonNode parent = parentPointer.isEmpty() ? root : at(root, parentPointer); + if (parent == null) { + throw new JsonPatchRejectedException("parent location does not exist: " + parentPointer); + } + if (parent.isObject()) { + ((ObjectNode) parent).set(key, value); + return root; + } + if (parent.isArray()) { + ArrayNode array = (ArrayNode) parent; + if (key.equals("-")) { + array.add(value); + return root; + } + int index = index(key, array.size(), true); + array.insert(index, value); + return root; + } + throw new JsonPatchRejectedException("cannot address into a scalar: " + parentPointer); + } + + private JsonNode remove(JsonNode root, String pointer) { + int lastSlash = pointer.lastIndexOf('/'); + String parentPointer = pointer.substring(0, lastSlash); + String key = unescape(pointer.substring(lastSlash + 1)); + JsonNode parent = parentPointer.isEmpty() ? root : at(root, parentPointer); + if (parent == null) { + throw new JsonPatchRejectedException("parent location does not exist: " + parentPointer); + } + if (parent.isObject()) { + ((ObjectNode) parent).remove(key); + return root; + } + if (parent.isArray()) { + ArrayNode array = (ArrayNode) parent; + array.remove(index(key, array.size(), false)); + return root; + } + throw new JsonPatchRejectedException("cannot address into a scalar: " + parentPointer); + } + + private static int index(String key, int size, boolean insert) { + int parsed; + try { + parsed = Integer.parseInt(key); + } catch (NumberFormatException notANumber) { + throw new JsonPatchRejectedException("array index expected, got: " + key); + } + // Bounds checked here rather than relying on the node's own behaviour: Jackson's insert() + // clamps out-of-range indices, so an index of 900 into a three-element array would silently + // append instead of failing. + int ceiling = insert ? size : size - 1; + if (parsed < 0 || parsed > ceiling) { + throw new JsonPatchRejectedException("array index out of range: " + key); + } + return parsed; + } + + private static String unescape(String token) { + // RFC 6901 section 3, in this order: ~1 first, then ~0. The reverse turns "~01" into "/" + // instead of "~1". + return token.replace("~1", "/").replace("~0", "~"); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/patch/JsonPatchDocument.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/patch/JsonPatchDocument.java new file mode 100644 index 00000000..9a35af69 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/patch/JsonPatchDocument.java @@ -0,0 +1,90 @@ +package dev.caskeleton.adapter.inbound.web.advanced.patch; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; + +/** + * A parsed RFC 6902 patch document. + * + *

Bounded in count as well as in pointer depth. The two multiply: each operation walks its + * pointer, so a thousand operations at a hundred segments each is a hundred thousand traversals of + * a document the server also has to deep-copy first. An operation limit alone does not bound the + * work and a depth limit alone does not either. + * + * @param operations the operations, in order + */ +public record JsonPatchDocument(List operations) { + + /** More than any real patch and few enough that the total traversal cost is bounded. */ + public static final int MAX_OPERATIONS = 100; + + public JsonPatchDocument { + operations = List.copyOf(Objects.requireNonNull(operations, "operations")); + if (operations.isEmpty()) { + throw new IllegalArgumentException( + "an empty patch document changes nothing; sending one is a client bug and answering 200 " + + "hides it"); + } + if (operations.size() > MAX_OPERATIONS) { + throw new JsonPatchRejectedException( + "a patch of " + + operations.size() + + " operations exceeds the " + + MAX_OPERATIONS + + " limit; each one walks its pointer over a document the server deep-copied first"); + } + } + + /** + * Parse a body. + * + * @param mapper the strict mapper + * @param body the request body + */ + public static JsonPatchDocument parse(ObjectMapper mapper, byte[] body) { + Objects.requireNonNull(mapper, "mapper"); + Objects.requireNonNull(body, "body"); + JsonNode root = mapper.readTree(body); + if (!root.isArray()) { + throw new JsonPatchRejectedException( + "an RFC 6902 patch is an array of operations; an object here is a merge patch sent to " + + "the wrong route, and merging it would report success having changed nothing"); + } + List parsed = + root.values().stream().map(JsonPatchDocument::parseOperation).toList(); + return new JsonPatchDocument(parsed); + } + + /** Whether any operation would modify the target. */ + public boolean mutating() { + return operations.stream().anyMatch(operation -> operation.kind().mutating()); + } + + private static JsonPatchOperation parseOperation(JsonNode node) { + if (!node.isObject()) { + throw new JsonPatchRejectedException("each patch operation is a JSON object"); + } + JsonNode op = node.get("op"); + JsonPatchOperationKind kind = + JsonPatchOperationKind.fromWire(op == null ? null : op.asString()) + .orElseThrow( + () -> + new JsonPatchRejectedException( + "unrecognised patch operation; RFC 6902 requires this to be an error " + + "rather than a skip, because skipping one turns a document the " + + "client believed was atomic into a partial application")); + JsonNode path = node.get("path"); + if (path == null || !path.isString()) { + throw new JsonPatchRejectedException("each patch operation names a path"); + } + JsonNode from = node.get("from"); + return new JsonPatchOperation( + kind, + path.asString(), + Optional.ofNullable(node.get("value")), + Optional.ofNullable(from).filter(JsonNode::isString).map(JsonNode::asString)); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/patch/JsonPatchOperation.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/patch/JsonPatchOperation.java new file mode 100644 index 00000000..79d2c290 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/patch/JsonPatchOperation.java @@ -0,0 +1,71 @@ +package dev.caskeleton.adapter.inbound.web.advanced.patch; + +import java.util.Objects; +import java.util.Optional; +import tools.jackson.databind.JsonNode; + +/** + * One RFC 6902 operation, with its members checked against what its kind requires. + * + *

Checked at construction rather than at application, because the document is atomic: an + * operation missing its {@code value} discovered halfway through is a document that has already + * modified the working copy. Everything that can be known before the first change is established + * before the first change. + * + * @param kind which operation + * @param path the pointer it acts on + * @param value the operand, present for add, replace and test + * @param from the source pointer, present for move and copy + */ +public record JsonPatchOperation( + JsonPatchOperationKind kind, String path, Optional value, Optional from) { + + public JsonPatchOperation { + Objects.requireNonNull(kind, "kind"); + Objects.requireNonNull(path, "path"); + Objects.requireNonNull(value, "value"); + Objects.requireNonNull(from, "from"); + if (!path.isEmpty() && !path.startsWith("/")) { + throw new IllegalArgumentException("a JSON Pointer is empty or starts with '/': " + path); + } + if (kind.requiresValue() != value.isPresent()) { + throw new IllegalArgumentException( + kind.wireName() + + (kind.requiresValue() + ? " requires a value member" + : " carries no value member, and one here means the client meant a different op")); + } + if (kind.requiresFrom() != from.isPresent()) { + throw new IllegalArgumentException( + kind.wireName() + + (kind.requiresFrom() ? " requires a from member" : " carries no from member")); + } + if (from.isPresent() && !from.get().isEmpty() && !from.get().startsWith("/")) { + throw new IllegalArgumentException("a JSON Pointer is empty or starts with '/'"); + } + if (kind == JsonPatchOperationKind.MOVE + && from.isPresent() + && path.startsWith(from.get() + "/")) { + // RFC 6902 section 4.4: the target cannot be inside the source. Moving a subtree into itself + // is not a thing that has a result, and an implementation that tries produces either an + // infinite structure or a silently truncated one. + throw new IllegalArgumentException("a move cannot target a location inside its own source"); + } + } + + /** An operation with a value. */ + public static JsonPatchOperation of(JsonPatchOperationKind kind, String path, JsonNode value) { + return new JsonPatchOperation(kind, path, Optional.of(value), Optional.empty()); + } + + /** A remove. */ + public static JsonPatchOperation remove(String path) { + return new JsonPatchOperation( + JsonPatchOperationKind.REMOVE, path, Optional.empty(), Optional.empty()); + } + + /** A move or a copy. */ + public static JsonPatchOperation relocate(JsonPatchOperationKind kind, String from, String path) { + return new JsonPatchOperation(kind, path, Optional.empty(), Optional.of(from)); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/patch/JsonPatchOperationKind.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/patch/JsonPatchOperationKind.java new file mode 100644 index 00000000..9c287c99 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/patch/JsonPatchOperationKind.java @@ -0,0 +1,83 @@ +package dev.caskeleton.adapter.inbound.web.advanced.patch; + +import java.util.Locale; +import java.util.Optional; + +/** + * The six RFC 6902 operations. + * + *

{@link #TEST} is the one that makes JSON Patch more than a list of edits: it is an assertion + * evaluated in sequence, and a failed one aborts the whole document. That is how a client expresses + * an optimistic-concurrency check that {@code If-Match} cannot — a precondition on one field rather + * than on the whole resource. + */ +public enum JsonPatchOperationKind { + + /** Insert a value. */ + ADD("add", true, false), + + /** Delete a value. */ + REMOVE("remove", false, false), + + /** Overwrite a value. */ + REPLACE("replace", true, false), + + /** Relocate a value. */ + MOVE("move", false, true), + + /** Duplicate a value. */ + COPY("copy", false, true), + + /** Assert a value, aborting the document if it does not hold. */ + TEST("test", true, false); + + private final String wireName; + private final boolean requiresValue; + private final boolean requiresFrom; + + JsonPatchOperationKind(String wireName, boolean requiresValue, boolean requiresFrom) { + this.wireName = wireName; + this.requiresValue = requiresValue; + this.requiresFrom = requiresFrom; + } + + /** The name as it appears in the {@code op} member. */ + public String wireName() { + return wireName; + } + + /** Whether the operation must carry a {@code value}. */ + public boolean requiresValue() { + return requiresValue; + } + + /** Whether it must carry a {@code from} pointer. */ + public boolean requiresFrom() { + return requiresFrom; + } + + /** Whether it can modify the target. {@code test} cannot. */ + public boolean mutating() { + return this != TEST; + } + + /** + * Parse an {@code op} member. + * + *

Empty for anything unrecognised, and the caller refuses. RFC 6902 requires an unrecognised + * operation to be an error rather than a skip: skipping one turns a document the client believed + * was applied atomically into a partial application. + */ + public static Optional fromWire(String op) { + if (op == null) { + return Optional.empty(); + } + String normalized = op.trim().toLowerCase(Locale.ROOT); + for (JsonPatchOperationKind kind : values()) { + if (kind.wireName.equals(normalized)) { + return Optional.of(kind); + } + } + return Optional.empty(); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/patch/JsonPatchRejectedException.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/patch/JsonPatchRejectedException.java new file mode 100644 index 00000000..550a1410 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/patch/JsonPatchRejectedException.java @@ -0,0 +1,11 @@ +package dev.caskeleton.adapter.inbound.web.advanced.patch; + +/** A JSON Patch document that will not be applied, and was not partially applied either. */ +public final class JsonPatchRejectedException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + public JsonPatchRejectedException(String message) { + super(message); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/patch/JsonPatchTestFailedException.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/patch/JsonPatchTestFailedException.java new file mode 100644 index 00000000..071674bf --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/patch/JsonPatchTestFailedException.java @@ -0,0 +1,31 @@ +package dev.caskeleton.adapter.inbound.web.advanced.patch; + +import java.util.Objects; + +/** + * A {@code test} operation did not hold, so nothing was applied. + * + *

Distinct from a rejection, because it means something different to the caller: the document + * was well-formed and permitted, and the resource simply was not in the state the client expected. + * That is a 409, not a 400, and a client that retries after re-reading will usually succeed. + * + *

The pointer is echoed because it came from the client's own patch. The value is not: it is the + * server's, and returning it would turn {@code test} into a read primitive for fields the caller + * may not read. + */ +public final class JsonPatchTestFailedException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + private final transient String pointer; + + public JsonPatchTestFailedException(String pointer) { + super("a test operation did not hold, so no operation was applied"); + this.pointer = Objects.requireNonNull(pointer, "pointer"); + } + + /** Which pointer failed. */ + public String pointer() { + return pointer; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/patch/JsonPointerAuthorization.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/patch/JsonPointerAuthorization.java new file mode 100644 index 00000000..bf47c21b --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/patch/JsonPointerAuthorization.java @@ -0,0 +1,95 @@ +package dev.caskeleton.adapter.inbound.web.advanced.patch; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +/** + * Which JSON Pointers a patch may address, and how deep. + * + *

Harder than the merge-patch equivalent, and worth stating why. A merge patch names top-level + * fields; a JSON Pointer addresses arbitrary depth, so {@code /profile/displayName} and {@code + * /profile} are different permissions over the same data, and an allowlist of prefixes has to + * decide whether permission on the parent implies permission on the child. + * + *

It does — a caller who may replace {@code /profile} wholesale can already set any field inside + * it — but the reverse must not: permission on {@code /profile/displayName} cannot grant {@code + * /profile}, because replacing the parent deletes every sibling. + * + *

Depth is bounded separately. A pointer's depth is the client's choice and each segment is a + * tree traversal, so an unbounded pointer is unbounded work per operation, multiplied by the + * operation limit. + */ +public final class JsonPointerAuthorization { + + /** Deeper than any real document, and bounded so the traversal cost is. */ + public static final int MAX_POINTER_DEPTH = 16; + + private final Set writablePrefixes; + + private JsonPointerAuthorization(Set writablePrefixes) { + this.writablePrefixes = Set.copyOf(writablePrefixes); + } + + /** Only pointers at or under these prefixes may be modified. */ + public static JsonPointerAuthorization allow(String... prefixes) { + Objects.requireNonNull(prefixes, "prefixes"); + if (prefixes.length == 0) { + throw new IllegalArgumentException( + "an empty allowlist refuses every patch; if the resource is not patchable, do not expose " + + "the route"); + } + for (String prefix : prefixes) { + if (prefix == null || !prefix.startsWith("/") || prefix.endsWith("/")) { + throw new IllegalArgumentException( + "a JSON Pointer prefix must start with '/' and not end with one: " + prefix); + } + } + return new JsonPointerAuthorization(Set.of(prefixes)); + } + + /** Whether a pointer may be modified. */ + public boolean writable(String pointer) { + if (pointer == null) { + return false; + } + if (depthOf(pointer) > MAX_POINTER_DEPTH) { + return false; + } + // At-or-under, never above. Permission on a child cannot grant the parent, because replacing + // the parent deletes every sibling the caller has no permission for. + return writablePrefixes.stream() + .anyMatch(prefix -> pointer.equals(prefix) || pointer.startsWith(prefix + "/")); + } + + /** + * Check every pointer a document names, including {@code from} pointers. + * + * @throws PatchAuthorizationException naming all of them + */ + public void validate(List operations) { + Objects.requireNonNull(operations, "operations"); + List refused = new ArrayList<>(); + for (JsonPatchOperation operation : operations) { + if (operation.kind().mutating() && !writable(operation.path())) { + refused.add(operation.path()); + } + // A move reads from its source and deletes it, so the source needs write permission too — + // checking only the destination lets a caller relocate data out of a field they may not + // touch. + operation.from().filter(from -> !writable(from)).ifPresent(refused::add); + } + if (!refused.isEmpty()) { + refused.sort(String::compareTo); + throw new PatchAuthorizationException(refused); + } + } + + private static int depthOf(String pointer) { + if (pointer.isEmpty() || pointer.equals("/")) { + return 0; + } + return (int) pointer.chars().filter(c -> c == '/').count(); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/patch/PatchAuthorizationException.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/patch/PatchAuthorizationException.java new file mode 100644 index 00000000..2136715c --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/patch/PatchAuthorizationException.java @@ -0,0 +1,27 @@ +package dev.caskeleton.adapter.inbound.web.advanced.patch; + +import java.util.List; +import java.util.Objects; + +/** + * A patch tried to touch something the caller may not. + * + *

Names the fields, because the caller needs to know which of them was refused and the names are + * from the server's allowlist rather than from the request — so they are safe to echo. + */ +public final class PatchAuthorizationException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + private final transient List refusedPaths; + + public PatchAuthorizationException(List refusedPaths) { + super("the patch touches fields this caller may not modify: " + refusedPaths); + this.refusedPaths = List.copyOf(Objects.requireNonNull(refusedPaths, "refusedPaths")); + } + + /** Which paths were refused. */ + public List refusedPaths() { + return refusedPaths; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/patch/PatchFieldAuthorization.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/patch/PatchFieldAuthorization.java new file mode 100644 index 00000000..a191d89b --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/patch/PatchFieldAuthorization.java @@ -0,0 +1,69 @@ +package dev.caskeleton.adapter.inbound.web.advanced.patch; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +/** + * Which top-level fields a merge patch may modify. + * + *

An allowlist, and the direction matters more here than almost anywhere else in the leaf. A + * merge patch is a partial document: the caller sends only what changes, so there is no DTO whose + * absent fields say "not permitted". Without an allowlist, the set of modifiable fields is whatever + * the target type happens to have — which grows every time somebody adds a field to it, silently, + * and includes the ones that should never have been client-writable. + * + *

A denylist would be the wrong shape for exactly that reason: it would have to be updated every + * time the type gains a field, and forgetting is the failure. + */ +public final class PatchFieldAuthorization { + + private final Set writableFields; + + private PatchFieldAuthorization(Set writableFields) { + this.writableFields = Set.copyOf(writableFields); + } + + /** Only these fields may be modified. */ + public static PatchFieldAuthorization allow(String... fields) { + Objects.requireNonNull(fields, "fields"); + if (fields.length == 0) { + throw new IllegalArgumentException( + "an empty allowlist refuses every patch; if the resource is not patchable, do not expose " + + "the route"); + } + return new PatchFieldAuthorization(Set.of(fields)); + } + + /** Whether a field may be modified. */ + public boolean writable(String field) { + return writableFields.contains(field); + } + + /** The allowlist, for the startup report. */ + public List writableFields() { + return writableFields.stream().sorted().toList(); + } + + /** + * Check every field a patch names. + * + * @throws PatchAuthorizationException naming all of them, not just the first + */ + public void validate(Iterable fields) { + Objects.requireNonNull(fields, "fields"); + List refused = new ArrayList<>(); + for (String field : fields) { + if (!writable(field)) { + refused.add(field); + } + } + if (!refused.isEmpty()) { + // All of them, so a caller fixing a patch does not discover the refusals one round trip at a + // time. + refused.sort(String::compareTo); + throw new PatchAuthorizationException(refused); + } + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/patch/PatchMediaType.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/patch/PatchMediaType.java new file mode 100644 index 00000000..f29e4898 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/patch/PatchMediaType.java @@ -0,0 +1,23 @@ +package dev.caskeleton.adapter.inbound.web.advanced.patch; + +/** + * The two patch media types, and the reason a route accepts exactly one. + * + *

They are not interchangeable and the difference is silent. {@code {"a": null}} as a merge + * patch deletes {@code a}; as a JSON patch it is not a patch at all — it is an object where an + * array was required. Worse in the other direction: a JSON patch array read as a merge patch is a + * document whose fields are array indices, which merges nothing and reports success. + * + *

So a route declares one, and a body sent with the other content type is a 415 rather than a + * best-effort guess. + */ +public final class PatchMediaType { + + /** RFC 7396. */ + public static final String MERGE_PATCH = "application/merge-patch+json"; + + /** RFC 6902. */ + public static final String JSON_PATCH = "application/json-patch+json"; + + private PatchMediaType() {} +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/patch/PatchResult.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/patch/PatchResult.java new file mode 100644 index 00000000..c6b49aec --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/patch/PatchResult.java @@ -0,0 +1,28 @@ +package dev.caskeleton.adapter.inbound.web.advanced.patch; + +import java.util.List; +import java.util.Objects; + +/** + * A patched value and what the patch actually did. + * + *

The changed-field list is not decoration. A merge patch that sets a field to the value it + * already had is indistinguishable from one that changed it, unless somebody compared — and the + * difference decides whether an audit entry is written and whether a version is bumped. + * + * @param value the patched object + * @param modifiedFields which fields the patch actually changed + * @param the target type + */ +public record PatchResult(T value, List modifiedFields) { + + public PatchResult { + Objects.requireNonNull(value, "value"); + modifiedFields = List.copyOf(Objects.requireNonNull(modifiedFields, "modifiedFields")); + } + + /** Whether the patch changed anything at all. */ + public boolean changed() { + return !modifiedFields.isEmpty(); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/ratelimit/RateLimitDraftHeaderWriter.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/ratelimit/RateLimitDraftHeaderWriter.java new file mode 100644 index 00000000..064bc2a2 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/ratelimit/RateLimitDraftHeaderWriter.java @@ -0,0 +1,73 @@ +package dev.caskeleton.adapter.inbound.web.advanced.ratelimit; + +import dev.caskeleton.adapter.inbound.web.ratelimit.RateLimitDecision; +import java.time.Duration; +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + +/** + * Writes the draft {@code RateLimit} headers, when they are turned on. + * + *

Additive only. This writer never touches the status, never touches {@code Retry-After}, and + * returns an empty map when disabled — so a deployment that turns it off behaves exactly as a + * Stable one, which is the property the rollback test asserts. + * + *

The reset value is a delta in seconds rather than a timestamp. Both drafts specify a delta, + * and the reason is clock skew: a client whose clock is two minutes fast reads an absolute reset + * time as already past and retries immediately, which is the behaviour the header exists to + * prevent. + */ +public final class RateLimitDraftHeaderWriter { + + private final RateLimitDraftProfile profile; + + public RateLimitDraftHeaderWriter(RateLimitDraftProfile profile) { + this.profile = Objects.requireNonNull(profile, "profile"); + } + + /** + * The headers for a decision. + * + * @param decision the Stable rate-limit decision + * @param now the current instant, for the reset delta + */ + public Map write(RateLimitDecision decision, Instant now) { + Objects.requireNonNull(decision, "decision"); + Objects.requireNonNull(now, "now"); + if (!profile.enabled()) { + return Map.of(); + } + long resetSeconds = secondsUntil(decision.resetAt(), now); + Map headers = new LinkedHashMap<>(); + if (profile.version().structured()) { + headers.put( + "RateLimit", + "limit=" + + decision.limit() + + ", remaining=" + + decision.remaining() + + ", reset=" + + resetSeconds); + headers.put("RateLimit-Policy", "q=" + decision.limit() + ";w=" + profile.windowSeconds()); + } else { + headers.put("RateLimit-Limit", Long.toString(decision.limit())); + headers.put("RateLimit-Remaining", Long.toString(decision.remaining())); + headers.put("RateLimit-Reset", Long.toString(resetSeconds)); + } + return Map.copyOf(headers); + } + + /** Which draft is being emitted, for the response artifact and the compatibility report. */ + public String draftLabel() { + return profile.version().label(); + } + + private static long secondsUntil(Instant resetAt, Instant now) { + long seconds = Duration.between(now, resetAt).toSeconds(); + // Never negative. A reset already in the past reads as "retry now" to a client that clamps and + // as an enormous unsigned number to one that does not. + return Math.max(seconds, 0L); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/ratelimit/RateLimitDraftProfile.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/ratelimit/RateLimitDraftProfile.java new file mode 100644 index 00000000..25ef35f4 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/ratelimit/RateLimitDraftProfile.java @@ -0,0 +1,36 @@ +package dev.caskeleton.adapter.inbound.web.advanced.ratelimit; + +import java.util.Objects; + +/** + * Whether draft rate-limit headers are emitted, and under which draft. + * + *

Off by default, and additive when on. The Stable contract is the 429 status and the {@code + * Retry-After} header; both are standardised, both are what a correct client acts on, and neither + * changes when this is enabled. These headers are extra information for clients that know to look. + * + * @param enabled whether the headers are written + * @param version which draft + * @param windowSeconds the quota window, needed for the policy field + */ +public record RateLimitDraftProfile( + boolean enabled, RateLimitDraftVersion version, long windowSeconds) { + + public RateLimitDraftProfile { + Objects.requireNonNull(version, "version"); + if (enabled && windowSeconds < 1) { + throw new IllegalArgumentException( + "the policy field states the window, and a window of zero describes no policy"); + } + } + + /** Off. */ + public static RateLimitDraftProfile disabled() { + return new RateLimitDraftProfile(false, RateLimitDraftVersion.DRAFT_11, 0); + } + + /** On, under a named draft. */ + public static RateLimitDraftProfile enabled(RateLimitDraftVersion version, long windowSeconds) { + return new RateLimitDraftProfile(true, version, windowSeconds); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/ratelimit/RateLimitDraftVersion.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/ratelimit/RateLimitDraftVersion.java new file mode 100644 index 00000000..52717388 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/ratelimit/RateLimitDraftVersion.java @@ -0,0 +1,41 @@ +package dev.caskeleton.adapter.inbound.web.advanced.ratelimit; + +/** + * Which draft of the IETF RateLimit header field specification is being emitted. + * + *

Named in the response and in the test artifacts, because the drafts are not compatible with + * each other and a client written against one silently misreads another. Draft 07 used separate + * {@code RateLimit-Limit}, {@code RateLimit-Remaining} and {@code RateLimit-Reset} fields; draft 11 + * folds them into one structured {@code RateLimit} field with a companion {@code RateLimit-Policy}. + * A client reading {@code RateLimit-Remaining} against a draft-11 server finds nothing and + * concludes it has no quota information — or, worse, treats the absent field as zero. + * + *

This is why the whole thing is Advanced and experimental: the header is not standardised, so + * emitting it is a promise the platform cannot keep across a version bump. + */ +public enum RateLimitDraftVersion { + + /** draft-ietf-httpapi-ratelimit-headers-07: three separate fields. */ + DRAFT_07("draft-07", false), + + /** draft-ietf-httpapi-ratelimit-headers-11: one structured field plus a policy field. */ + DRAFT_11("draft-11", true); + + private final String label; + private final boolean structured; + + RateLimitDraftVersion(String label, boolean structured) { + this.label = label; + this.structured = structured; + } + + /** The version, as it appears in a report. */ + public String label() { + return label; + } + + /** Whether this draft uses the single structured field. */ + public boolean structured() { + return structured; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/release/WebAdvancedPromotionGate.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/release/WebAdvancedPromotionGate.java new file mode 100644 index 00000000..05981626 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/release/WebAdvancedPromotionGate.java @@ -0,0 +1,138 @@ +package dev.caskeleton.adapter.inbound.web.advanced.release; + +import dev.caskeleton.adapter.inbound.web.advanced.WebAdvancedFeature; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +/** + * What one web Advanced capability must show before production. + * + *

Per capability, because they fail in unrelated ways and at unrelated scales. Streaming fails + * at connection count; virtual threads fail when a synchronized block pins a carrier under load; + * XML fails on one crafted document. Evidence for any of them is not evidence for the others. + * + *

{@code stableBehaviourUnchanged} is the one condition every capability shares, and it is what + * the rollback test exists to establish. If turning the feature off does not restore Stable + * behaviour exactly, then the feature was never optional and every deployment has it. + * + * @param requiredSuites the suites that must have passed + * @param minimumSoak how long it must run under production-like load + * @param securityReviewed whether a security review was done, where the capability needs one + * @param rollbackValidated whether disabling it was actually exercised + * @param stableBehaviourUnchanged whether Stable behaviour is identical with the feature off + */ +public record WebAdvancedPromotionGate( + Set requiredSuites, + Duration minimumSoak, + boolean securityReviewed, + boolean rollbackValidated, + boolean stableBehaviourUnchanged) { + + public WebAdvancedPromotionGate { + requiredSuites = Set.copyOf(Objects.requireNonNull(requiredSuites, "requiredSuites")); + Objects.requireNonNull(minimumSoak, "minimumSoak"); + if (requiredSuites.isEmpty()) { + throw new IllegalArgumentException( + "a gate requiring no suite passes everything, which is worse than no gate because it " + + "reads as one"); + } + if (minimumSoak.isNegative() || minimumSoak.isZero()) { + throw new IllegalArgumentException( + "a zero soak promotes on the strength of a green build; every failure mode these " + + "capabilities have needs load or time to appear"); + } + } + + /** The gate for a capability, with the suites its own failure modes need. */ + public static WebAdvancedPromotionGate forFeature(WebAdvancedFeature feature) { + Objects.requireNonNull(feature, "feature"); + return new WebAdvancedPromotionGate(suitesFor(feature), soakFor(feature), false, false, false); + } + + private static Set suitesFor(WebAdvancedFeature feature) { + return switch (feature) { + case MVC_VIRTUAL_THREADS -> + Set.of("web:test", "webCrossStackParityTest", "virtual-thread-admission", "pinning-jfr"); + case WEBFLUX_BLOCKING_BRIDGE -> + Set.of("web:test", "blocking-bridge-bounded", "event-loop-guard"); + case JSON_MERGE_PATCH, JSON_PATCH -> Set.of("web:test", "patch-security", "patch-atomicity"); + case SSE, NDJSON, JSON_SEQUENCE -> + Set.of( + "web:test", + "streaming-soak-10k", + "slow-consumer-bounded", + "cancellation-propagation", + "pod-drain"); + case FUNCTIONAL_WEBFLUX -> Set.of("web:test", "functional-route-parity"); + case CBOR, XML -> Set.of("web:test", "codec-security", "codec-budget"); + case OPENAPI_32 -> Set.of("web:test", "openapi-32-toolchain-matrix"); + case RATELIMIT_DRAFT_HEADERS -> Set.of("web:test", "ratelimit-draft-headers"); + }; + } + + private static Duration soakFor(WebAdvancedFeature feature) { + // Longer where the failure needs sustained load: streaming holds connections, and virtual + // threads only pin under contention that a short run does not produce. + return switch (feature) { + case SSE, NDJSON, JSON_SEQUENCE -> Duration.ofHours(24); + case MVC_VIRTUAL_THREADS, WEBFLUX_BLOCKING_BRIDGE -> Duration.ofHours(24); + default -> Duration.ofHours(8); + }; + } + + /** Whether this capability needs a security review before it ships. */ + public static boolean needsSecurityReview(WebAdvancedFeature feature) { + // Each of these takes attacker-supplied bytes into a new parser or lets a caller address + // arbitrary parts of a resource. + return feature == WebAdvancedFeature.XML + || feature == WebAdvancedFeature.CBOR + || feature == WebAdvancedFeature.JSON_PATCH + || feature == WebAdvancedFeature.JSON_MERGE_PATCH; + } + + /** + * Why this may not be promoted yet. + * + * @param feature which capability + * @param passedSuites which suites actually passed + * @param observedSoak how long it ran under production-like load + */ + public List blockers( + WebAdvancedFeature feature, Set passedSuites, Duration observedSoak) { + Objects.requireNonNull(feature, "feature"); + Objects.requireNonNull(passedSuites, "passedSuites"); + Objects.requireNonNull(observedSoak, "observedSoak"); + List blockers = new ArrayList<>(); + List missing = + requiredSuites.stream().filter(suite -> !passedSuites.contains(suite)).sorted().toList(); + if (!missing.isEmpty()) { + blockers.add("suites not passed: " + missing); + } + if (observedSoak.compareTo(minimumSoak) < 0) { + blockers.add("soak " + observedSoak + " is short of the required " + minimumSoak); + } + if (needsSecurityReview(feature) && !securityReviewed) { + blockers.add( + "no security review; this capability takes attacker-supplied bytes into a parser or " + + "lets a caller address arbitrary parts of a resource"); + } + if (!rollbackValidated) { + blockers.add("rollback not exercised; a flag nobody has turned off is not known to turn off"); + } + if (!stableBehaviourUnchanged) { + blockers.add( + "Stable behaviour is not identical with the feature off, so the feature was never " + + "optional and every deployment has it"); + } + return List.copyOf(blockers); + } + + /** Whether promotion may proceed. */ + public boolean promotable( + WebAdvancedFeature feature, Set passedSuites, Duration observedSoak) { + return blockers(feature, passedSuites, observedSoak).isEmpty(); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/release/WebAdvancedReleaseManifest.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/release/WebAdvancedReleaseManifest.java new file mode 100644 index 00000000..ec4533d0 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/release/WebAdvancedReleaseManifest.java @@ -0,0 +1,90 @@ +package dev.caskeleton.adapter.inbound.web.advanced.release; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * The evidence a release claims to have, and the evidence a release needs. + * + *

Two separate things, deliberately, because the gap between them is the finding. A manifest + * that only listed what was collected would always look complete — it lists what it lists. The + * required set is written down independently so that missing evidence is a diff rather than an + * absence nobody notices. + */ +public final class WebAdvancedReleaseManifest { + + /** Evidence every Advanced release must carry, whatever is enabled. */ + public static final List REQUIRED = + List.of( + "stable-release-baseline", + "streaming-soak-10k", + "slow-consumer-bounded", + "cancellation-propagation", + "patch-security", + "codec-security", + "rollback-disabled-profile"); + + private final Map evidence; + + private WebAdvancedReleaseManifest(Map evidence) { + this.evidence = Map.copyOf(evidence); + } + + /** Start an empty manifest. */ + public static Builder builder() { + return new Builder(); + } + + /** Whether a named piece of evidence was recorded. */ + public boolean has(String name) { + return evidence.containsKey(name); + } + + /** Where a piece of evidence came from. */ + public java.util.Optional reference(String name) { + return java.util.Optional.ofNullable(evidence.get(name)); + } + + /** What is required and absent. */ + public List missing() { + return REQUIRED.stream().filter(name -> !has(name)).toList(); + } + + /** Whether the manifest is complete. */ + public boolean complete() { + return missing().isEmpty(); + } + + /** Collects evidence references. */ + public static final class Builder { + + private final Map evidence = new LinkedHashMap<>(); + + private Builder() {} + + /** + * Record one piece of evidence. + * + * @param name what it is + * @param reference where to find it — a workflow run, a report path, a review link + */ + public Builder record(String name, String reference) { + Objects.requireNonNull(name, "name"); + Objects.requireNonNull(reference, "reference"); + if (reference.isBlank()) { + throw new IllegalArgumentException( + "evidence needs somewhere to look; a name with no reference is a claim, and a manifest " + + "of claims is what this exists instead of"); + } + evidence.put(name, reference); + return this; + } + + /** Freeze it. */ + public WebAdvancedReleaseManifest build() { + return new WebAdvancedReleaseManifest(evidence); + } + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/stream/StreamId.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/stream/StreamId.java new file mode 100644 index 00000000..d75f5d54 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/stream/StreamId.java @@ -0,0 +1,27 @@ +package dev.caskeleton.adapter.inbound.web.advanced.stream; + +import java.util.Objects; +import java.util.regex.Pattern; + +/** + * One stream's identity, as it appears to the client. + * + *

Constrained rather than free, because it is echoed in every envelope and reaches logs and + * metric tags. An unconstrained identifier there is an unbounded-cardinality tag and, if it came + * from the client, an injection point into whatever reads the log. + * + * @param value the identifier + */ +public record StreamId(String value) { + + private static final Pattern GRAMMAR = Pattern.compile("[a-zA-Z0-9][a-zA-Z0-9._:-]{0,63}"); + + public StreamId { + Objects.requireNonNull(value, "value"); + if (!GRAMMAR.matcher(value).matches()) { + throw new IllegalArgumentException( + "a stream id must be 1..64 characters of [a-zA-Z0-9._:-] starting alphanumeric; it is " + + "echoed into logs and metric tags, so it cannot be free text"); + } + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/stream/StreamSequence.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/stream/StreamSequence.java new file mode 100644 index 00000000..236d3247 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/stream/StreamSequence.java @@ -0,0 +1,35 @@ +package dev.caskeleton.adapter.inbound.web.advanced.stream; + +/** + * Where an item sits in its stream. + * + *

Counting from 1, so that 0 is not a valid position and cannot be produced by an uninitialised + * field. A stream whose first item claims position 0 and one whose sequence was never set look + * identical, and the client cannot tell whether it missed something. + * + * @param value the position + */ +public record StreamSequence(long value) implements Comparable { + + public StreamSequence { + if (value < 1) { + throw new IllegalArgumentException( + "stream positions count from 1, so an unset field cannot pass for the first item"); + } + } + + /** The first position. */ + public static StreamSequence first() { + return new StreamSequence(1); + } + + /** The position after this one. */ + public StreamSequence next() { + return new StreamSequence(value + 1); + } + + @Override + public int compareTo(StreamSequence other) { + return Long.compare(value, other.value); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/stream/TerminationDecision.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/stream/TerminationDecision.java new file mode 100644 index 00000000..498a44c8 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/stream/TerminationDecision.java @@ -0,0 +1,43 @@ +package dev.caskeleton.adapter.inbound.web.advanced.stream; + +import dev.caskeleton.adapter.inbound.web.error.ProblemCode; +import java.util.Objects; +import java.util.Optional; + +/** + * What to do about a failure, given whether the response has been committed. + * + *

Exactly one of the two is present, and the invariant is checked. A decision carrying both + * would let a caller write a problem document *and* a terminal record, which is the half-stream + * half-JSON body that started this. + * + * @param termination how the stream ends, absent when the response can still become an HTTP error + * @param httpStatusChange the problem code to answer with, absent once committed + * @param safeMessage client-safe text for the terminal record + */ +public record TerminationDecision( + Optional termination, + Optional httpStatusChange, + String safeMessage) { + + public TerminationDecision { + Objects.requireNonNull(termination, "termination"); + Objects.requireNonNull(httpStatusChange, "httpStatusChange"); + Objects.requireNonNull(safeMessage, "safeMessage"); + if (termination.isPresent() == httpStatusChange.isPresent()) { + throw new IllegalArgumentException( + "a failure is answered either as an HTTP problem or as a terminal stream record, never " + + "both and never neither"); + } + } + + /** Nothing was written yet, so this can still be an ordinary error response. */ + public static TerminationDecision problem(ProblemCode code, String safeMessage) { + return new TerminationDecision(Optional.empty(), Optional.of(code), safeMessage); + } + + /** The response is committed; the failure goes into the stream. */ + public static TerminationDecision stream(WebStreamTermination how, String safeMessage) { + return new TerminationDecision(Optional.of(how), Optional.empty(), safeMessage); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/stream/WebPartialResponseException.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/stream/WebPartialResponseException.java new file mode 100644 index 00000000..48cbcd06 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/stream/WebPartialResponseException.java @@ -0,0 +1,41 @@ +package dev.caskeleton.adapter.inbound.web.advanced.stream; + +import java.util.Objects; + +/** + * A stream failed after its headers were written. + * + *

Distinct from every other failure in the leaf because the usual remedy is unavailable: the + * status is already 200 and cannot be changed. Throwing this rather than a general exception is how + * the error handler knows not to try — an exception handler that attempts to write a problem + * document onto a committed response produces a body that is half stream and half JSON, which no + * client can parse. + */ +public final class WebPartialResponseException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + private final transient StreamId streamId; + private final transient long deliveredCount; + + /** + * @param streamId which stream + * @param deliveredCount how many items the client already has + * @param cause what failed + */ + public WebPartialResponseException(StreamId streamId, long deliveredCount, Throwable cause) { + super("stream failed after " + deliveredCount + " items were already delivered", cause); + this.streamId = Objects.requireNonNull(streamId, "streamId"); + this.deliveredCount = deliveredCount; + } + + /** Which stream. */ + public StreamId streamId() { + return streamId; + } + + /** How many items the client already has, which is what makes this unrecoverable. */ + public long deliveredCount() { + return deliveredCount; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/stream/WebStreamDrainCoordinator.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/stream/WebStreamDrainCoordinator.java new file mode 100644 index 00000000..a025c5b5 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/stream/WebStreamDrainCoordinator.java @@ -0,0 +1,83 @@ +package dev.caskeleton.adapter.inbound.web.advanced.stream; + +import java.time.Duration; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.LongSupplier; + +/** + * Shuts streams down in the order that does not produce a thundering herd. + * + *

Three steps, and each is there because skipping it produces a specific failure. + * + *

**Stop accepting first.** If readiness stays up while streams are draining, the load balancer + * keeps sending new ones and the drain never finishes — the node ends up killed by the deadline + * with streams still open, which is the ungraceful shutdown the drain was meant to avoid. + * + *

**Ask before cutting.** A client told to reconnect while the server can still serve it goes to + * another node in an orderly way. A client whose socket is cut retries immediately, and if every + * client's socket is cut at the same instant, every client retries at the same instant. + * + *

**Then cut anyway.** A drain without a deadline is a shutdown that never completes, because + * there is always one client that does not reconnect. + */ +public final class WebStreamDrainCoordinator { + + private final WebStreamRegistry registry; + private final LongSupplier clockMillis; + private final AtomicBoolean accepting = new AtomicBoolean(true); + + /** + * @param registry the streams to drain + * @param clockMillis the clock, supplied so the deadline is testable without sleeping + */ + public WebStreamDrainCoordinator(WebStreamRegistry registry, LongSupplier clockMillis) { + this.registry = Objects.requireNonNull(registry, "registry"); + this.clockMillis = Objects.requireNonNull(clockMillis, "clockMillis"); + } + + /** Whether new streams are still admitted. */ + public boolean accepting() { + return accepting.get(); + } + + /** + * Run the drain. + * + * @param deadline how long to wait before forcing the remainder closed + * @param pollInterval how often to re-check, in millis of the supplied clock + * @return how many streams had to be forced + */ + public int beginDrain(Duration deadline, long pollInterval) { + Objects.requireNonNull(deadline, "deadline"); + accepting.set(false); + registry.sessions().forEach(WebStreamSession::requestReconnect); + long expiry = clockMillis.getAsLong() + deadline.toMillis(); + while (registry.activeStreams() > 0 && clockMillis.getAsLong() < expiry) { + awaitPoll(pollInterval); + } + int remaining = registry.activeStreams(); + registry + .sessions() + .forEach( + session -> { + session.forceClose(WebStreamTermination.ABRUPT_CLOSE); + registry.deregister(session.streamId()); + }); + return remaining; + } + + private static void awaitPoll(long pollInterval) { + if (pollInterval <= 0) { + return; + } + try { + Thread.sleep(pollInterval); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + // Stop waiting. An interrupted drain proceeds to the forced close rather than looping, + // because the interrupt is the container saying the deadline it gave us has passed. + throw new IllegalStateException("drain interrupted", interrupted); + } + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/stream/WebStreamEnvelope.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/stream/WebStreamEnvelope.java new file mode 100644 index 00000000..58a14b28 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/stream/WebStreamEnvelope.java @@ -0,0 +1,68 @@ +package dev.caskeleton.adapter.inbound.web.advanced.stream; + +import dev.caskeleton.adapter.inbound.web.error.ProblemCode; +import java.util.Objects; + +/** + * What can appear on a stream. + * + *

Sealed and three-way, because the client has to be able to tell three outcomes apart and the + * transport cannot tell it. Once response headers are written the HTTP status is fixed at 200, so a + * stream that ends because a dependency failed and one that ends because it finished look the same + * at the transport layer — both are a closed connection after a 200. The difference has to be *in + * the stream*, which is what {@link Failure} and {@link Complete} are for. + * + *

A client that sees neither has been cut off mid-stream. That is the third outcome and it is + * the one that must not be silently treated as completion. + * + * @param the item type + */ +public sealed interface WebStreamEnvelope { + + /** Which stream this belongs to. */ + StreamId streamId(); + + /** One item. */ + record Item(StreamId streamId, StreamSequence sequence, T data) + implements WebStreamEnvelope { + + public Item { + Objects.requireNonNull(streamId, "streamId"); + Objects.requireNonNull(sequence, "sequence"); + Objects.requireNonNull(data, "data"); + } + } + + /** + * The stream ended because something failed. + * + *

Carries a catalog code rather than a status, because the status is already sent. The message + * is client-safe text — a stream error reaches the client with no problem-detail sanitiser in the + * path, so whatever is put here is what the client sees. + */ + record Failure(StreamId streamId, StreamSequence sequence, ProblemCode code, String message) + implements WebStreamEnvelope { + + public Failure { + Objects.requireNonNull(streamId, "streamId"); + Objects.requireNonNull(sequence, "sequence"); + Objects.requireNonNull(code, "code"); + Objects.requireNonNull(message, "message"); + } + } + + /** The stream ended because it finished. */ + record Complete(StreamId streamId, StreamSequence lastSequence) + implements WebStreamEnvelope { + + public Complete { + Objects.requireNonNull(streamId, "streamId"); + Objects.requireNonNull(lastSequence, "lastSequence"); + } + } + + /** Whether this envelope ends the stream. */ + default boolean terminal() { + return this instanceof Failure || this instanceof Complete; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/stream/WebStreamErrorPolicy.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/stream/WebStreamErrorPolicy.java new file mode 100644 index 00000000..b0737714 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/stream/WebStreamErrorPolicy.java @@ -0,0 +1,58 @@ +package dev.caskeleton.adapter.inbound.web.advanced.stream; + +import java.util.Objects; + +/** + * Whether a stream that has already delivered items may retry, and what the client should do. + * + *

The question this answers is not "was there an error" but "can anything still be done about + * it". A stream that failed before its first item is an ordinary request that failed and can be + * retried whole; one that failed after item 400 cannot, because a retry re-delivers items 1 to 400 + * and the client has no way to know they are repeats unless it tracked positions. + * + * @param resumable whether the client may reconnect with a position and continue + * @param retryWholeStream whether the client should start the stream again from the beginning + */ +public record WebStreamErrorPolicy(boolean resumable, boolean retryWholeStream) { + + public WebStreamErrorPolicy { + if (resumable && retryWholeStream) { + throw new IllegalArgumentException( + "a resumable stream must not also be retried whole; doing both re-delivers everything " + + "the client already has and it cannot tell the repeats apart"); + } + } + + /** No replay source: the client starts over. */ + public static WebStreamErrorPolicy startOver() { + return new WebStreamErrorPolicy(false, true); + } + + /** A replay source exists: the client reconnects with its last position. */ + public static WebStreamErrorPolicy resumeFromPosition() { + return new WebStreamErrorPolicy(true, false); + } + + /** Nothing the client can do; the stream is over. */ + public static WebStreamErrorPolicy nothingToDo() { + return new WebStreamErrorPolicy(false, false); + } + + /** + * What the client should be told to do after this termination. + * + * @param termination how the stream ended + * @param delivered how many items reached the client + */ + public WebStreamErrorPolicy afterDelivering(WebStreamTermination termination, long delivered) { + Objects.requireNonNull(termination, "termination"); + if (termination == WebStreamTermination.NORMAL_COMPLETE) { + return nothingToDo(); + } + if (delivered == 0) { + // Nothing was delivered, so a fresh attempt costs nothing and duplicates nothing. + return startOver(); + } + return resumable ? resumeFromPosition() : nothingToDo(); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/stream/WebStreamEvidence.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/stream/WebStreamEvidence.java new file mode 100644 index 00000000..f02a2b1f --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/stream/WebStreamEvidence.java @@ -0,0 +1,135 @@ +package dev.caskeleton.adapter.inbound.web.advanced.stream; + +import java.time.Instant; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.locks.ReentrantLock; + +/** + * What a stream actually delivered, as opposed to what it was asked to. + * + *

The reason this exists rather than a counter: a stream's HTTP status is 200 from the moment + * headers are written, so the response tells you nothing about whether it worked. Without a record + * of what was delivered and how it ended, a stream that emitted three items and died is + * indistinguishable in every log and metric from one that emitted three items because there were + * three. + * + *

Monotonicity is enforced rather than assumed. A repeated or regressing position means the + * source produced a duplicate or the writer retried, and a client using the position to deduplicate + * would silently drop the second item. + */ +public final class WebStreamEvidence { + + private final StreamId streamId; + private final Instant startedAt; + private final ReentrantLock lock = new ReentrantLock(); + private StreamSequence lastDelivered; + private long deliveredCount; + private WebStreamTermination termination; + private Instant endedAt; + + public WebStreamEvidence(StreamId streamId, Instant startedAt) { + this.streamId = Objects.requireNonNull(streamId, "streamId"); + this.startedAt = Objects.requireNonNull(startedAt, "startedAt"); + } + + /** Which stream. */ + public StreamId streamId() { + return streamId; + } + + /** + * Record that an item reached the transport. + * + * @throws IllegalStateException if the position does not advance, or the stream already ended + */ + public void recordDelivered(StreamSequence sequence) { + Objects.requireNonNull(sequence, "sequence"); + lock.lock(); + try { + if (termination != null) { + throw new IllegalStateException( + "an item was delivered after the stream ended as " + + termination + + "; the terminal envelope has already told the client there is nothing more"); + } + if (lastDelivered != null && sequence.compareTo(lastDelivered) <= 0) { + throw new IllegalStateException( + "stream position went from " + + lastDelivered.value() + + " to " + + sequence.value() + + "; a client deduplicating on position would silently drop this item"); + } + lastDelivered = sequence; + deliveredCount++; + } finally { + lock.unlock(); + } + } + + /** Record how the stream ended. The first call wins; the end happens once. */ + public void recordTermination(WebStreamTermination how, Instant at) { + Objects.requireNonNull(how, "how"); + Objects.requireNonNull(at, "at"); + lock.lock(); + try { + if (termination == null) { + termination = how; + endedAt = at; + } + } finally { + lock.unlock(); + } + } + + /** How many items reached the transport. */ + public long deliveredCount() { + lock.lock(); + try { + return deliveredCount; + } finally { + lock.unlock(); + } + } + + /** The last position delivered, absent when nothing was. */ + public Optional lastDelivered() { + lock.lock(); + try { + return Optional.ofNullable(lastDelivered); + } finally { + lock.unlock(); + } + } + + /** How it ended, absent while it is still running. */ + public Optional termination() { + lock.lock(); + try { + return Optional.ofNullable(termination); + } finally { + lock.unlock(); + } + } + + /** + * Whether the client can tell the stream is finished. + * + *

False for an abrupt close, which is the case worth counting: the client sees a closed + * connection after a 200 and has no way to distinguish that from a completed stream. + */ + public boolean clientKnowsItEnded() { + return termination().map(WebStreamTermination::clientWasTold).orElse(false); + } + + /** How long it ran, so far or in total. */ + public java.time.Duration ageAt(Instant now) { + lock.lock(); + try { + return java.time.Duration.between(startedAt, endedAt == null ? now : endedAt); + } finally { + lock.unlock(); + } + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/stream/WebStreamPolicy.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/stream/WebStreamPolicy.java new file mode 100644 index 00000000..9c399d92 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/stream/WebStreamPolicy.java @@ -0,0 +1,74 @@ +package dev.caskeleton.adapter.inbound.web.advanced.stream; + +import java.time.Duration; +import java.util.Objects; + +/** + * The bounds one stream runs under. + * + *

Every field here is a bound whose absence is invisible until production. A stream with no + * maximum age is a connection held for as long as a client cares to hold it; with no heartbeat, a + * disconnected client is indistinguishable from a quiet one and the server keeps its resources + * forever; with no buffer bound, a slow consumer's backlog is held in the server's heap. + * + * @param heartbeatInterval how often to write a keepalive when there is nothing else to send + * @param idleTimeout how long a stream may produce nothing before it is closed + * @param maxStreamAge the longest a stream may live regardless of activity + * @param maxBufferedItems how many items may await a slow consumer before the stream is closed + * @param maxItemBytes the ceiling on one serialized item + */ +public record WebStreamPolicy( + Duration heartbeatInterval, + Duration idleTimeout, + Duration maxStreamAge, + int maxBufferedItems, + int maxItemBytes) { + + public WebStreamPolicy { + Objects.requireNonNull(heartbeatInterval, "heartbeatInterval"); + Objects.requireNonNull(idleTimeout, "idleTimeout"); + Objects.requireNonNull(maxStreamAge, "maxStreamAge"); + requirePositive(heartbeatInterval, "heartbeatInterval"); + requirePositive(idleTimeout, "idleTimeout"); + requirePositive(maxStreamAge, "maxStreamAge"); + if (heartbeatInterval.compareTo(idleTimeout) >= 0) { + throw new IllegalArgumentException( + "the heartbeat must be shorter than the idle timeout, or the server times out its own " + + "healthy streams between beats"); + } + if (idleTimeout.compareTo(maxStreamAge) > 0) { + throw new IllegalArgumentException( + "an idle timeout longer than the maximum age is unreachable, so the stream never closes " + + "for idleness and the setting reads as protection that is not there"); + } + if (maxBufferedItems < 1) { + throw new IllegalArgumentException( + "a stream with no buffer allowance cannot absorb a single slow write"); + } + if (maxItemBytes < 1) { + throw new IllegalArgumentException("an item ceiling of zero admits nothing"); + } + } + + /** A conventional profile: 15s heartbeat, 60s idle, 30 minutes, 256 items, 256KB each. */ + public static WebStreamPolicy conventional() { + return new WebStreamPolicy( + Duration.ofSeconds(15), Duration.ofSeconds(60), Duration.ofMinutes(30), 256, 262_144); + } + + /** Whether a serialized item may be written. */ + public boolean itemWithinBounds(int itemBytes) { + return itemBytes >= 0 && itemBytes <= maxItemBytes; + } + + /** Whether another item may be buffered for a consumer that is behind. */ + public boolean mayBuffer(int currentlyBuffered) { + return currentlyBuffered < maxBufferedItems; + } + + private static void requirePositive(Duration value, String field) { + if (value.isNegative() || value.isZero()) { + throw new IllegalArgumentException(field + " must be positive"); + } + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/stream/WebStreamRegistry.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/stream/WebStreamRegistry.java new file mode 100644 index 00000000..a117c8af --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/stream/WebStreamRegistry.java @@ -0,0 +1,92 @@ +package dev.caskeleton.adapter.inbound.web.advanced.stream; + +import java.time.Duration; +import java.time.Instant; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Every stream currently open on this node. + * + *

The reason a registry is needed at all: a stream is a request that has not finished, so the + * usual "in-flight requests" accounting does not bound it — a node with a hundred open streams and + * no other traffic looks idle by request rate and is holding a hundred connections, a hundred + * buffers and whatever each of their sources is subscribed to. + * + *

It is also the only place a shutdown can find them. Without it, a drain has nothing to drain + * and the container's graceful shutdown closes the sockets, which the clients see as an abrupt + * failure rather than an orderly reconnect. + */ +public final class WebStreamRegistry { + + private final Map sessions = new ConcurrentHashMap<>(); + private final int maxConcurrentStreams; + + public WebStreamRegistry(int maxConcurrentStreams) { + if (maxConcurrentStreams < 1) { + throw new IllegalArgumentException("a registry admitting nothing serves no stream"); + } + this.maxConcurrentStreams = maxConcurrentStreams; + } + + /** + * Admit a stream, or refuse it because the node is full. + * + * @return whether it was admitted + */ + public boolean register(WebStreamSession session) { + Objects.requireNonNull(session, "session"); + // Checked and inserted under computeIfAbsent so two concurrent admissions cannot both see room + // for the last slot. + boolean[] admitted = new boolean[1]; + sessions.compute( + session.streamId(), + (id, existing) -> { + if (existing != null) { + throw new IllegalStateException( + "two streams registered as " + + id.value() + + "; one would be unreachable to a drain"); + } + if (sessions.size() >= maxConcurrentStreams) { + admitted[0] = false; + return null; + } + admitted[0] = true; + return session; + }); + return admitted[0]; + } + + /** Forget a stream that has ended. */ + public boolean deregister(StreamId streamId) { + return sessions.remove(streamId) != null; + } + + /** Every open stream. */ + public Collection sessions() { + return List.copyOf(sessions.values()); + } + + /** How many are open. */ + public int activeStreams() { + return sessions.size(); + } + + /** The ceiling. */ + public int maxConcurrentStreams() { + return maxConcurrentStreams; + } + + /** Streams that have exceeded the policy's maximum age. */ + public List olderThan(Duration maxAge, Instant now) { + Objects.requireNonNull(maxAge, "maxAge"); + Objects.requireNonNull(now, "now"); + return sessions.values().stream() + .filter(session -> !now.isBefore(session.startedAt().plus(maxAge))) + .toList(); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/stream/WebStreamSession.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/stream/WebStreamSession.java new file mode 100644 index 00000000..ba72cd04 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/stream/WebStreamSession.java @@ -0,0 +1,34 @@ +package dev.caskeleton.adapter.inbound.web.advanced.stream; + +import java.time.Instant; + +/** + * One live stream, as the registry sees it. + * + *

An interface rather than a class because the two stacks hold entirely different things — an + * {@code SseEmitter} on MVC, a subscription on WebFlux — and the registry only needs to be able to + * ask them to stop. + */ +public interface WebStreamSession { + + /** Which stream. */ + StreamId streamId(); + + /** When it started. */ + Instant startedAt(); + + /** + * Ask the client to reconnect. + * + *

Sent before a drain deadline rather than at it. A client told to reconnect while the server + * is still able to serve it goes somewhere else in an orderly way; a client whose connection is + * cut at the deadline retries immediately, and every one of them retries at the same moment. + */ + void requestReconnect(); + + /** Close it now, whether or not the client is ready. */ + void forceClose(WebStreamTermination reason); + + /** Whether it is still open. */ + boolean open(); +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/stream/WebStreamTermination.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/stream/WebStreamTermination.java new file mode 100644 index 00000000..4665b9c0 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/stream/WebStreamTermination.java @@ -0,0 +1,45 @@ +package dev.caskeleton.adapter.inbound.web.advanced.stream; + +/** + * How a stream ended. + * + *

Six outcomes, and the reason for the count is that "the connection closed" is the observable + * form of all of them. Distinguishing them is only possible if the server records which one it + * caused, because from the socket they are identical. + */ +public enum WebStreamTermination { + + /** The source finished and a {@code Complete} envelope was written. */ + NORMAL_COMPLETE, + + /** Something failed after commit, and an {@code Error} envelope was written. */ + TERMINAL_ERROR_RECORD, + + /** The client went away. Not a fault. */ + CLIENT_DISCONNECTED, + + /** Nothing was sent for longer than the policy allows. */ + IDLE_TIMEOUT, + + /** The stream reached its maximum age. */ + MAX_AGE, + + /** + * The connection failed before a terminal envelope could be written. + * + *

The one outcome where the client cannot know the stream is over rather than merely quiet. + * Recorded explicitly so it does not get counted as a normal completion in the metrics, which is + * where a rising rate of mid-stream failures would otherwise hide. + */ + ABRUPT_CLOSE; + + /** Whether the client received an explicit end. */ + public boolean clientWasTold() { + return this == NORMAL_COMPLETE || this == TERMINAL_ERROR_RECORD; + } + + /** Whether this indicates a server-side fault. */ + public boolean serverFault() { + return this == TERMINAL_ERROR_RECORD || this == ABRUPT_CLOSE; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/stream/WebStreamTerminationMapper.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/stream/WebStreamTerminationMapper.java new file mode 100644 index 00000000..5317b9e5 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/stream/WebStreamTerminationMapper.java @@ -0,0 +1,53 @@ +package dev.caskeleton.adapter.inbound.web.advanced.stream; + +import dev.caskeleton.adapter.inbound.web.error.ProblemCode; +import java.util.Objects; + +/** + * Decides how a stream failure is reported, based on whether anything has been written yet. + * + *

The whole class is one branch, and the branch is the contract. Before commit a stream failure + * is an ordinary error and gets an RFC 9457 problem document with a real status. After commit the + * status is 200 and cannot be changed — attempting it produces a response whose body is a stream + * followed by a JSON object, which no client parses and every proxy caches as a success. + * + *

The message is deliberately generic. A terminal stream record reaches the client without the + * problem-detail sanitiser in the path, so an exception message put here goes straight out. + */ +public final class WebStreamTerminationMapper { + + private static final String COMMITTED_MESSAGE = + "the stream ended early because a dependency failed"; + + /** The standard mapper. Stateless; there is nothing to configure. */ + public static WebStreamTerminationMapper standard() { + return new WebStreamTerminationMapper(); + } + + /** + * Map a failure. + * + * @param responseCommitted whether any byte of the response has been written + * @param failure what went wrong + */ + public TerminationDecision mapFailure(boolean responseCommitted, Throwable failure) { + Objects.requireNonNull(failure, "failure"); + if (!responseCommitted) { + return TerminationDecision.problem(ProblemCode.DEPENDENCY_FAILURE, COMMITTED_MESSAGE); + } + return TerminationDecision.stream( + WebStreamTermination.TERMINAL_ERROR_RECORD, COMMITTED_MESSAGE); + } + + /** + * Map a failure that could not even write a terminal record. + * + *

Separate because the client learns nothing at all: it sees a connection closed after a 200. + * Counting these as normal completions is how a rising rate of mid-stream failures stays + * invisible. + */ + public TerminationDecision mapWriteFailure() { + return TerminationDecision.stream( + WebStreamTermination.ABRUPT_CLOSE, "the connection failed before the stream could end"); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/stream/encoding/JsonSequenceFraming.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/stream/encoding/JsonSequenceFraming.java new file mode 100644 index 00000000..8dafd6ac --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/stream/encoding/JsonSequenceFraming.java @@ -0,0 +1,68 @@ +package dev.caskeleton.adapter.inbound.web.advanced.stream.encoding; + +import java.nio.charset.StandardCharsets; +import java.util.Objects; + +/** + * RFC 7464 framing: a record separator before each record, a line feed after it. + * + *

The reason this format exists, and the reason it is worth the extra byte over NDJSON: the + * separator comes *first*. A truncated record leaves a partial value with no trailing newline, and + * the next {@code 0x1E} unambiguously starts the next one — so a parser resynchronises at the next + * record rather than trying to parse the truncation joined to what follows. NDJSON cannot do that, + * because its delimiter is also the thing that got truncated away. + * + *

This matters for a stream specifically. A truncated NDJSON line is not a hypothetical: it is + * what a client reads when the connection dies mid-write, which for a long-lived stream is the + * normal way it ends. + */ +public final class JsonSequenceFraming { + + /** The RFC 7464 record separator. */ + public static final byte RECORD_SEPARATOR = 0x1E; + + /** The trailing line feed. */ + public static final byte LINE_FEED = 0x0A; + + private JsonSequenceFraming() {} + + /** + * Frame one already-serialized JSON value. + * + * @param json the serialized value, without framing + */ + public static byte[] frame(byte[] json) { + Objects.requireNonNull(json, "json"); + byte[] framed = new byte[json.length + 2]; + framed[0] = RECORD_SEPARATOR; + System.arraycopy(json, 0, framed, 1, json.length); + framed[framed.length - 1] = LINE_FEED; + return framed; + } + + /** + * Whether a byte sequence is correctly framed. + * + *

Used by the contract tests rather than by the writer. A writer that checks its own output is + * checking that its own two lines of array copying work; the value is in a consumer-side check + * that the bytes on the wire are what a third-party parser needs. + */ + public static boolean framed(byte[] candidate) { + Objects.requireNonNull(candidate, "candidate"); + return candidate.length >= 2 + && candidate[0] == RECORD_SEPARATOR + && candidate[candidate.length - 1] == LINE_FEED; + } + + /** + * The payload inside a framed record. + * + * @throws IllegalArgumentException if it is not framed + */ + public static String payload(byte[] framed) { + if (!framed(framed)) { + throw new IllegalArgumentException("not an RFC 7464 record"); + } + return new String(framed, 1, framed.length - 2, StandardCharsets.UTF_8); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/stream/encoding/NdjsonFraming.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/stream/encoding/NdjsonFraming.java new file mode 100644 index 00000000..f35df39f --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/stream/encoding/NdjsonFraming.java @@ -0,0 +1,56 @@ +package dev.caskeleton.adapter.inbound.web.advanced.stream.encoding; + +import java.nio.charset.StandardCharsets; +import java.util.Objects; + +/** + * NDJSON framing: one JSON value per line, exactly one line feed after each. + * + *

Two rules, and both are places a writer goes wrong. A record containing a raw newline breaks + * the framing for everything after it, because the consumer's line split is the only boundary there + * is — so the serializer must not pretty-print, and this checks that it did not. A missing trailing + * newline on the last record leaves a consumer blocked waiting for a line that never arrives. + */ +public final class NdjsonFraming { + + /** The line delimiter. */ + public static final byte LINE_FEED = 0x0A; + + private NdjsonFraming() {} + + /** + * Frame one already-serialized JSON value. + * + * @param json the serialized value, which must contain no line feed + */ + public static byte[] frame(byte[] json) { + Objects.requireNonNull(json, "json"); + for (byte b : json) { + if (b == LINE_FEED) { + throw new IllegalArgumentException( + "an NDJSON record cannot contain a line feed; the consumer's line split is the only " + + "record boundary there is, so one pretty-printed value breaks every record " + + "after it"); + } + } + byte[] framed = new byte[json.length + 1]; + System.arraycopy(json, 0, framed, 0, json.length); + framed[json.length] = LINE_FEED; + return framed; + } + + /** Whether a whole response body is correctly framed. */ + public static boolean framed(byte[] body) { + Objects.requireNonNull(body, "body"); + return body.length > 0 && body[body.length - 1] == LINE_FEED; + } + + /** How many records a body holds. */ + public static long recordCount(byte[] body) { + if (!framed(body)) { + throw new IllegalArgumentException( + "an unterminated NDJSON body leaves the consumer waiting for a line that never arrives"); + } + return new String(body, StandardCharsets.UTF_8).lines().count(); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/stream/encoding/NdjsonRecord.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/stream/encoding/NdjsonRecord.java new file mode 100644 index 00000000..a2f9ed41 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/stream/encoding/NdjsonRecord.java @@ -0,0 +1,61 @@ +package dev.caskeleton.adapter.inbound.web.advanced.stream.encoding; + +import java.util.Objects; + +/** + * One line of an {@code application/x-ndjson} response. + * + *

Separate from {@code WebStreamEnvelope} rather than reusing it, because the two answer + * different questions. The envelope is what the platform knows about an item; this is what goes on + * the wire, and it is flat and small on purpose — an NDJSON consumer parses one line at a time with + * no framing beyond the newline, so anything nested here is something a client has to unwrap before + * it can tell an item from a terminal record. + * + * @param the item type + */ +public sealed interface NdjsonRecord { + + /** One item. */ + record Item(long sequence, T data) implements NdjsonRecord { + + public Item { + if (sequence < 1) { + throw new IllegalArgumentException("stream positions count from 1"); + } + Objects.requireNonNull(data, "data"); + } + } + + /** The stream ended because something failed. */ + record Failure(long sequence, String code, String message) implements NdjsonRecord { + + public Failure { + Objects.requireNonNull(code, "code"); + Objects.requireNonNull(message, "message"); + } + } + + /** + * The stream ended because it finished. + * + *

Written even when the stream is empty. A zero-byte NDJSON body and a connection that failed + * before the first line are the same thing to a client, and the completion marker is what + * separates them. + */ + record Complete(long lastSequence) implements NdjsonRecord {} + + /** One item, at a position. */ + static NdjsonRecord item(long sequence, T data) { + return new Item<>(sequence, data); + } + + /** The terminal marker. */ + static NdjsonRecord complete(long lastSequence) { + return new Complete<>(lastSequence); + } + + /** Whether this ends the stream. */ + default boolean terminal() { + return this instanceof Failure || this instanceof Complete; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/stream/encoding/StreamFraming.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/stream/encoding/StreamFraming.java new file mode 100644 index 00000000..6ac34c5c --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/stream/encoding/StreamFraming.java @@ -0,0 +1,34 @@ +package dev.caskeleton.adapter.inbound.web.advanced.stream.encoding; + +/** + * Which record framing a streaming response uses. + * + *

Here rather than on either writer, because the two runtimes are mutually exclusive and neither + * may name the other. A framing enum living on the servlet writer would make the reactive one + * depend on it, which is the edge the whole MVC/WebFlux separation exists to prevent — and it would + * be an edge created by an enum, not by any actual coupling. + */ +public enum StreamFraming { + + /** One JSON value per line. */ + NDJSON(StreamMediaType.NDJSON), + + /** RFC 7464: a record separator before each value, a line feed after. */ + JSON_SEQUENCE(StreamMediaType.JSON_SEQ); + + private final String mediaType; + + StreamFraming(String mediaType) { + this.mediaType = mediaType; + } + + /** The media type this framing is served as. */ + public String mediaType() { + return mediaType; + } + + /** Frame one already-serialized value. */ + public byte[] frame(byte[] json) { + return this == NDJSON ? NdjsonFraming.frame(json) : JsonSequenceFraming.frame(json); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/stream/encoding/StreamItemTooLargeException.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/stream/encoding/StreamItemTooLargeException.java new file mode 100644 index 00000000..3b1fde93 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/stream/encoding/StreamItemTooLargeException.java @@ -0,0 +1,33 @@ +package dev.caskeleton.adapter.inbound.web.advanced.stream.encoding; + +/** + * One stream item exceeded the policy's per-item ceiling. + * + *

Thrown before any byte of it is framed, so the stream can end with a terminal error record + * rather than a truncated one. The alternative — discovering the size after the separator is + * written — leaves the consumer resynchronising past a partial record, which is recoverable for + * JSON-seq and not for NDJSON. + */ +public final class StreamItemTooLargeException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + private final transient int actualBytes; + private final transient int maxBytes; + + public StreamItemTooLargeException(int actualBytes, int maxBytes) { + super("a stream item of " + actualBytes + " bytes exceeds the " + maxBytes + " byte ceiling"); + this.actualBytes = actualBytes; + this.maxBytes = maxBytes; + } + + /** How big it was. */ + public int actualBytes() { + return actualBytes; + } + + /** What the ceiling is. */ + public int maxBytes() { + return maxBytes; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/stream/encoding/StreamMediaType.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/stream/encoding/StreamMediaType.java new file mode 100644 index 00000000..19658b0f --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/stream/encoding/StreamMediaType.java @@ -0,0 +1,22 @@ +package dev.caskeleton.adapter.inbound.web.advanced.stream.encoding; + +/** + * The media types the streaming adapters produce. + * + *

Constants rather than string literals at each writer, because these appear in the response + * header, in the route's {@code produces}, in the OpenAPI document and in the content-negotiation + * policy — and a typo in any one of them produces a 406 that looks like a client problem. + */ +public final class StreamMediaType { + + /** Server-sent events, per the HTML specification. */ + public static final String SSE = "text/event-stream"; + + /** Newline-delimited JSON. Not registered with IANA; the {@code x-} prefix is the convention. */ + public static final String NDJSON = "application/x-ndjson"; + + /** RFC 7464 JSON text sequences. */ + public static final String JSON_SEQ = "application/json-seq"; + + private StreamMediaType() {} +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/stream/encoding/StreamRecordEncoder.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/stream/encoding/StreamRecordEncoder.java new file mode 100644 index 00000000..0500d947 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/stream/encoding/StreamRecordEncoder.java @@ -0,0 +1,66 @@ +package dev.caskeleton.adapter.inbound.web.advanced.stream.encoding; + +import dev.caskeleton.adapter.inbound.web.advanced.stream.WebStreamPolicy; +import java.nio.charset.StandardCharsets; +import java.util.Objects; +import tools.jackson.databind.ObjectMapper; + +/** + * Turns one record into the bytes that go on the wire, in whichever framing the route uses. + * + *

One class for both framings rather than two, because the part that goes wrong is shared: the + * value has to be serialized compactly, its size has to be checked against the policy, and only + * then is it framed. Two encoders would be two places for the size check to be forgotten, and it is + * the check that stops one oversized item from being the whole response. + * + *

Compactness is not a preference here. NDJSON's record boundary is the newline, so a + * pretty-printed value breaks every record after it — {@link NdjsonFraming} refuses one, and this + * is where the mapper that would produce it is pinned. + */ +public final class StreamRecordEncoder { + + private final ObjectMapper mapper; + private final WebStreamPolicy policy; + + /** + * @param mapper the strict mapper the rest of the leaf uses, configured not to indent + * @param policy the per-item size bound + */ + public StreamRecordEncoder(ObjectMapper mapper, WebStreamPolicy policy) { + this.mapper = Objects.requireNonNull(mapper, "mapper"); + this.policy = Objects.requireNonNull(policy, "policy"); + } + + /** One NDJSON line, newline included. */ + public byte[] ndjson(Object record) { + return NdjsonFraming.frame(serialize(record)); + } + + /** One RFC 7464 record, separator and line feed included. */ + public byte[] jsonSequence(Object record) { + return JsonSequenceFraming.frame(serialize(record)); + } + + /** + * The {@code data:} payload of one SSE event. + * + *

Returned as text rather than bytes because {@code SseEmitter} and {@code ServerSentEvent} + * both take the payload and do their own framing — writing the {@code data:} prefix here would + * produce a doubled one. + */ + public String sseData(Object record) { + return new String(serialize(record), StandardCharsets.UTF_8); + } + + private byte[] serialize(Object record) { + Objects.requireNonNull(record, "record"); + byte[] json = mapper.writeValueAsBytes(record); + if (!policy.itemWithinBounds(json.length)) { + // Refused before framing, so the caller can send a terminal error instead of a half-written + // record. Discovering the size after the separator is on the wire leaves a truncated record + // that the consumer has to resynchronise past. + throw new StreamItemTooLargeException(json.length, policy.maxItemBytes()); + } + return json; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/stream/replay/GapAndDuplicateGuard.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/stream/replay/GapAndDuplicateGuard.java new file mode 100644 index 00000000..b381609e --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/stream/replay/GapAndDuplicateGuard.java @@ -0,0 +1,69 @@ +package dev.caskeleton.adapter.inbound.web.advanced.stream.replay; + +import dev.caskeleton.adapter.inbound.web.advanced.stream.StreamSequence; +import java.util.Objects; +import java.util.Optional; + +/** + * Watches the seam where replay hands over to live delivery. + * + *

That seam is where both failures live, and neither is visible in either half on its own. The + * replay ends at whatever the source had when it was asked; live starts at whatever it was + * publishing by the time the subscription was established. Between those two moments the source + * kept moving. + * + *

If live starts *behind* where replay ended, the client is sent items it already has — and + * because they are well-formed, it applies them twice. If live starts *ahead*, items are missing + * and the positions on either side of the gap are both valid, so nothing in the data says so. + * + *

Both are detected by watching positions, which is the only place the information exists. + */ +public final class GapAndDuplicateGuard { + + private StreamSequence lastSeen; + + /** + * Judge the next position. + * + * @param sequence the position about to be delivered + * @return what is wrong with it, empty when it follows on correctly + */ + public Optional observe(StreamSequence sequence) { + Objects.requireNonNull(sequence, "sequence"); + if (lastSeen == null) { + lastSeen = sequence; + return Optional.empty(); + } + long expected = lastSeen.value() + 1; + if (sequence.value() == expected) { + lastSeen = sequence; + return Optional.empty(); + } + if (sequence.value() <= lastSeen.value()) { + // Not advanced: the duplicate is dropped rather than delivered, and lastSeen stays put. + return Optional.of(SeamFault.DUPLICATE); + } + lastSeen = sequence; + return Optional.of(SeamFault.GAP); + } + + /** The last position that was accepted, absent before the first. */ + public Optional lastSeen() { + return Optional.ofNullable(lastSeen); + } + + /** What can go wrong at the replay-to-live seam. */ + public enum SeamFault { + + /** Live delivery started behind where replay ended. The item is dropped. */ + DUPLICATE, + + /** + * Live delivery started ahead of where replay ended. + * + *

Not recoverable by skipping: the missing items are gone from the client's view and the + * positions either side of the hole are both valid, so it has no way to notice. + */ + GAP + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/stream/replay/MessagingReplayBridge.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/stream/replay/MessagingReplayBridge.java new file mode 100644 index 00000000..40abdf8b --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/stream/replay/MessagingReplayBridge.java @@ -0,0 +1,137 @@ +package dev.caskeleton.adapter.inbound.web.advanced.stream.replay; + +import dev.caskeleton.adapter.inbound.web.advanced.stream.StreamSequence; +import dev.caskeleton.application.realtime.LiveEventReplayPort; +import dev.caskeleton.application.realtime.ReplayCursorUnavailableException; +import dev.caskeleton.application.realtime.ReplayWindow; +import java.util.List; +import java.util.Objects; +import java.util.function.Function; + +/** + * {@link WebStreamReplaySource} over the application's durable event log. + * + *

The web module stores no history, and this is what that decision looks like in code: the + * bridge holds a port and a decoder and nothing else. A copy here would have its own retention, its + * own eviction and its own opinion about ordering, and the two would diverge silently because both + * would look plausible. + * + *

An expired cursor becomes {@link ReplayCursorExpiredException} rather than an empty replay. + * Serving from the oldest available position would deliver contiguous positions with a hole in the + * middle, and a client that cannot see a gap does not resnapshot — which is the one thing that + * would fix it. + * + * @param the item type the payload decodes to + */ +public final class MessagingReplayBridge implements WebStreamReplaySource { + + /** How many events one replay call loads. The port caps this again on its own side. */ + public static final int PAGE_SIZE = 200; + + private final LiveEventReplayPort replay; + private final Function decoder; + + /** + * @param replay the durable history + * @param decoder turns a stored payload into the item the stream carries + */ + public MessagingReplayBridge(LiveEventReplayPort replay, Function decoder) { + this.replay = Objects.requireNonNull(replay, "replay"); + this.decoder = Objects.requireNonNull(decoder, "decoder"); + } + + @Override + public boolean holds(WebStreamResumeCursor cursor) { + Objects.requireNonNull(cursor, "cursor"); + Position position = Position.parse(cursor); + if (position == null) { + return false; + } + // Not "an empty window", which is what a failure used to produce here. An empty window is a + // real answer — a stream that holds nothing, whose current client can still subscribe live — + // and a store nobody could read is the absence of an answer. Collapsing them would tell a + // client its cursor is fine during an outage and then fail the replay. + return window(position).map(w -> w.canResumeAfter(position.value())).orElse(false); + } + + @Override + public List> replayAfter(WebStreamResumeCursor cursor) { + Objects.requireNonNull(cursor, "cursor"); + Position position = Position.parse(cursor); + if (position == null) { + // A cursor this bridge did not mint. Refused rather than treated as "start from the + // beginning": a client sending an unreadable cursor believes it has a position, and silently + // replaying everything hands it the whole history as though it were the gap it asked for. + throw new ReplayCursorExpiredException(cursor); + } + try { + return replay.replayAfter(position.streamId(), position.value(), PAGE_SIZE).stream() + .map( + event -> + new Replayed<>( + new StreamSequence(event.position()), decoder.apply(event.payload()))) + .toList(); + } catch (ReplayCursorUnavailableException expired) { + Objects.requireNonNull(expired); + throw new ReplayCursorExpiredException(cursor); + } + } + + @Override + public StreamSequence liveBoundary(List> replayed) { + Objects.requireNonNull(replayed, "replayed"); + if (replayed.isEmpty()) { + // Nothing was replayed, so live starts wherever it starts and the gap guard is what will + // notice if that is not where the client left off. Returning position 1 here would claim a + // boundary this bridge does not know. + return StreamSequence.first(); + } + return replayed.get(replayed.size() - 1).sequence(); + } + + private java.util.Optional window(Position position) { + try { + return java.util.Optional.of(replay.window(position.streamId())); + } catch (RuntimeException unavailable) { + // Empty means "no answer", not "no history". The caller turns that into a resnapshot, which + // is expensive and correct; treating it as a readable empty window would say the opposite. + Objects.requireNonNull(unavailable); + return java.util.Optional.empty(); + } + } + + /** + * A cursor split into the stream it names and the position within it. + * + *

{@code Last-Event-ID} is one opaque string to the client, and it has to carry both: a + * position alone would be ambiguous the moment a deployment serves more than one stream, and the + * client cannot be trusted to send the stream separately because it is the same header either + * way. + */ + private record Position(String streamId, long value) { + + static Position parse(WebStreamResumeCursor cursor) { + String raw = cursor.value(); + int separator = raw.lastIndexOf(':'); + if (separator <= 0 || separator == raw.length() - 1) { + return null; + } + try { + long position = Long.parseLong(raw.substring(separator + 1)); + if (position < 0) { + return null; + } + return new Position(raw.substring(0, separator), position); + } catch (NumberFormatException malformed) { + return null; + } + } + } + + /** Renders the cursor a client should send back to continue after a position. */ + public static WebStreamResumeCursor cursorFor(String streamId, StreamSequence position) { + Objects.requireNonNull(streamId, "streamId"); + Objects.requireNonNull(position, "position"); + return new WebStreamResumeCursor(streamId + ":" + position.value()); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/stream/replay/ReplayCursorExpiredException.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/stream/replay/ReplayCursorExpiredException.java new file mode 100644 index 00000000..be9694c1 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/stream/replay/ReplayCursorExpiredException.java @@ -0,0 +1,28 @@ +package dev.caskeleton.adapter.inbound.web.advanced.stream.replay; + +import java.util.Objects; + +/** + * The client's cursor names a position the source no longer holds. + * + *

Thrown rather than skipped past. Silently resuming from the oldest available position would + * deliver a stream with a hole in it that the client cannot see: the positions are contiguous from + * where the replay started, so nothing about the data says events are missing. An explicit + * resnapshot-required answer costs the client a full re-read and is the only honest option. + */ +public final class ReplayCursorExpiredException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + private final transient WebStreamResumeCursor cursor; + + public ReplayCursorExpiredException(WebStreamResumeCursor cursor) { + super("the resume cursor is older than the retained history; a resnapshot is required"); + this.cursor = Objects.requireNonNull(cursor, "cursor"); + } + + /** The cursor that could not be honoured. */ + public WebStreamResumeCursor cursor() { + return cursor; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/stream/replay/WebStreamReplaySource.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/stream/replay/WebStreamReplaySource.java new file mode 100644 index 00000000..5630c921 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/stream/replay/WebStreamReplaySource.java @@ -0,0 +1,39 @@ +package dev.caskeleton.adapter.inbound.web.advanced.stream.replay; + +import dev.caskeleton.adapter.inbound.web.advanced.stream.StreamSequence; +import java.util.List; + +/** + * Where retained history comes from. + * + *

An interface, and the web module implements none of it. The web module must not store durable + * event history: it would be a second copy of state the messaging platform already owns, with its + * own retention, its own eviction and its own opinion about ordering — and the two would diverge + * silently, because both would look plausible. + * + * @param the item type + */ +public interface WebStreamReplaySource { + + /** Whether the source still holds the position the cursor names. */ + boolean holds(WebStreamResumeCursor cursor); + + /** + * Everything retained after a cursor. + * + * @throws ReplayCursorExpiredException if the position has been evicted + */ + List> replayAfter(WebStreamResumeCursor cursor); + + /** The position live delivery should start from, given what replay returned. */ + StreamSequence liveBoundary(List> replayed); + + /** + * One retained item. + * + * @param sequence where it sat in the stream + * @param data the item + * @param the item type + */ + record Replayed(StreamSequence sequence, T data) {} +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/stream/replay/WebStreamResumeCursor.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/stream/replay/WebStreamResumeCursor.java new file mode 100644 index 00000000..0355a264 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/stream/replay/WebStreamResumeCursor.java @@ -0,0 +1,28 @@ +package dev.caskeleton.adapter.inbound.web.advanced.stream.replay; + +import java.util.Objects; +import java.util.regex.Pattern; + +/** + * A client's {@code Last-Event-ID}, as a value that has been checked. + * + *

It arrives as a request header written by whatever the client chose to send, and it is used to + * address a durable event source. Both halves of that sentence are why it is constrained here + * rather than passed through: an unchecked cursor reaches a store query, and a store query built + * from a client string is the oldest injection there is. + * + * @param value the opaque cursor + */ +public record WebStreamResumeCursor(String value) { + + private static final Pattern GRAMMAR = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._:+/=-]{0,127}"); + + public WebStreamResumeCursor { + Objects.requireNonNull(value, "value"); + if (!GRAMMAR.matcher(value).matches()) { + throw new IllegalArgumentException( + "a resume cursor arrives as a client-written header and is used to address a durable " + + "store, so it is checked rather than passed through"); + } + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/virtualthread/AdmissionRefusedException.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/virtualthread/AdmissionRefusedException.java new file mode 100644 index 00000000..91ecd9a9 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/virtualthread/AdmissionRefusedException.java @@ -0,0 +1,25 @@ +package dev.caskeleton.adapter.inbound.web.advanced.virtualthread; + +/** + * An arrival was refused because the concurrency limit was full. + * + *

A refusal, not a failure. It maps to 503 with a {@code Retry-After}, and it is the outcome the + * limit exists to produce — a request refused in a millisecond is strictly better for the client + * than the same request accepted and timed out thirty seconds later behind a full connection pool. + */ +public final class AdmissionRefusedException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + private final transient int limit; + + public AdmissionRefusedException(int limit) { + super("the concurrency limit is full"); + this.limit = limit; + } + + /** The limit that was full. */ + public int limit() { + return limit; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/virtualthread/VirtualThreadAdmissionGuard.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/virtualthread/VirtualThreadAdmissionGuard.java new file mode 100644 index 00000000..b66aed53 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/virtualthread/VirtualThreadAdmissionGuard.java @@ -0,0 +1,99 @@ +package dev.caskeleton.adapter.inbound.web.advanced.virtualthread; + +import java.time.Duration; +import java.util.Objects; +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; + +/** + * Keeps the concurrency limit that the thread pool used to provide. + * + *

On a platform-thread deployment the pool size *is* the admission limit — arrivals beyond it + * queue in the connector and are eventually refused. Virtual threads remove that pool, so the limit + * has to be an explicit thing or it is not a thing at all. + * + *

This is deliberately not a thread pool. Bounding the *threads* would put the waiting back and + * throw away what virtual threads bought; bounding the *concurrent use cases* keeps the cheap + * waiting and keeps the downstream budgets protected. Ten thousand virtual threads may exist while + * a hundred of them hold permits and the rest are refused at the door. + */ +public final class VirtualThreadAdmissionGuard { + + private final Semaphore permits; + private final int limit; + private final Duration acquireTimeout; + private final AtomicInteger active = new AtomicInteger(); + private final AtomicInteger peakActive = new AtomicInteger(); + private final AtomicLong rejected = new AtomicLong(); + + /** + * @param limit how many use cases may run at once + * @param acquireTimeout how long an arrival waits for a permit before being refused + */ + public VirtualThreadAdmissionGuard(int limit, Duration acquireTimeout) { + if (limit < 1) { + throw new IllegalArgumentException("an admission limit below one admits nothing"); + } + Objects.requireNonNull(acquireTimeout, "acquireTimeout"); + if (acquireTimeout.isNegative()) { + throw new IllegalArgumentException("a negative wait is not a wait"); + } + this.limit = limit; + this.acquireTimeout = acquireTimeout; + // Fair, so a burst does not starve the arrivals that were already waiting. Unfair semaphores + // are faster and here the difference is a request that waits forever while newer ones overtake + // it, which the client experiences as a random timeout. + this.permits = new Semaphore(limit, true); + } + + /** + * Run work under the limit. + * + * @param work what to run + * @param its result + * @throws AdmissionRefusedException if no permit became available in time + */ + public T run(java.util.concurrent.Callable work) throws Exception { + Objects.requireNonNull(work, "work"); + if (!permits.tryAcquire(acquireTimeout.toMillis(), TimeUnit.MILLISECONDS)) { + rejected.incrementAndGet(); + throw new AdmissionRefusedException(limit); + } + int current = active.incrementAndGet(); + peakActive.accumulateAndGet(current, Math::max); + try { + return work.call(); + } finally { + active.decrementAndGet(); + permits.release(); + } + } + + /** How many are running right now. */ + public int active() { + return active.get(); + } + + /** + * The most that ever ran at once. + * + *

The number a load test asserts against. If it exceeds the limit, the guard is not being + * applied — which is the failure this whole class exists to make visible, and it is invisible + * from throughput. + */ + public int peakActive() { + return peakActive.get(); + } + + /** How many arrivals were refused. */ + public long rejectedCount() { + return rejected.get(); + } + + /** The limit. */ + public int limit() { + return limit; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/virtualthread/VirtualThreadProfile.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/virtualthread/VirtualThreadProfile.java new file mode 100644 index 00000000..c3eccee7 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/virtualthread/VirtualThreadProfile.java @@ -0,0 +1,78 @@ +package dev.caskeleton.adapter.inbound.web.advanced.virtualthread; + +import java.util.List; + +/** + * The virtual-thread MVC profile, and the budgets it explicitly does not change. + * + *

Virtual threads remove the cost of a thread waiting. They do not remove the reason the waiting + * was bounded. A platform-thread MVC deployment has an implicit concurrency limit — the thread pool + * — and that limit is usually what has been protecting the database pool, the outbound HTTP + * bulkhead and every downstream service from the full arrival rate. Switching to virtual threads + * deletes that limit without deleting anything that depended on it. + * + *

The result is not a slow system. It is a system that accepts ten thousand concurrent requests, + * queues all of them on a twenty-connection database pool, and times out every single one — having + * done no useful work at all. The load that used to be shed at the front door is now shed at the + * back, after the cost of accepting it. + * + *

So this profile carries the downstream budgets and refuses to be constructed claiming they + * grew. + * + * @param enabled whether request handling runs on virtual threads + * @param admissionLimit the concurrency limit that still applies + * @param databasePoolSize the connection pool, unchanged + * @param outboundBulkhead the HTTP client bulkhead, unchanged + */ +public record VirtualThreadProfile( + boolean enabled, int admissionLimit, int databasePoolSize, int outboundBulkhead) { + + public VirtualThreadProfile { + if (enabled) { + if (admissionLimit < 1) { + throw new IllegalArgumentException( + "virtual threads without an admission limit accept every arrival and queue all of " + + "them on the downstream budgets, which times out work that would have " + + "succeeded had it been refused"); + } + if (databasePoolSize < 1 || outboundBulkhead < 1) { + throw new IllegalArgumentException( + "the downstream budgets must be stated, because the whole point is that they did not " + + "change"); + } + } + } + + /** Off. */ + public static VirtualThreadProfile disabled() { + return new VirtualThreadProfile(false, 0, 0, 0); + } + + /** + * Whether the admission limit is low enough that the downstream budgets are not the bottleneck. + * + *

Not a hard refusal, because a deployment can legitimately admit more than its pool when the + * work is not all database-bound. It is reported at startup so the choice is a choice. + */ + public boolean admissionFitsDownstreamBudgets() { + return !enabled || admissionLimit <= databasePoolSize + outboundBulkhead; + } + + /** What has to be watched once this is on, and is not watched otherwise. */ + public List requiredObservations() { + return List.of( + "jdk.VirtualThreadPinned JFR events: a synchronized block held across a blocking call " + + "pins the carrier thread, and enough pinned carriers is a deadlock the thread dump " + + "does not obviously show", + "carrier pool queue depth: the ForkJoinPool behind virtual threads is bounded by CPU " + + "count, so pinning starves everything", + "admission rejections: they should rise under load, and if they do not, the limit is not " + + "being applied", + "downstream wait time: the signal that admission is admitting more than the pools serve"); + } + + /** The property that turns this on. */ + public static String propertyName() { + return "backend.web.advanced.virtual-threads.enabled"; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/webflux/ControlledBlockingBridge.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/webflux/ControlledBlockingBridge.java new file mode 100644 index 00000000..ee140492 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/webflux/ControlledBlockingBridge.java @@ -0,0 +1,70 @@ +package dev.caskeleton.adapter.inbound.web.advanced.webflux; + +import dev.caskeleton.adapter.inbound.web.advanced.blockingbridge.BlockingBridgeBudget; +import java.util.Objects; +import java.util.concurrent.Callable; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Scheduler; + +/** + * Runs a registered blocking operation off the event loop, under a bound. + * + *

The permit is acquired **inside** the callable, not at assembly. Acquiring outside would take + * a slot when the pipeline is built rather than when it is subscribed, so a {@code Mono} that was + * assembled and never subscribed would hold one for ever, and a request cancelled between the two + * would leak one. Assembly-time side effects are the classic Reactor bug and this is the shape it + * takes here. + * + *

The release is in the same {@code finally} as the work, and deliberately **not** in a {@code + * doFinally}. A {@code doFinally} sees the subscriber's cancel signal, which arrives while the + * callable is still blocked on a thread — releasing there hands the permit to another caller while + * the first is still holding the database connection it was supposed to be accounting for, and the + * bound then permits more concurrent work than it says. + * + *

The consequence is that cancellation does not interrupt the blocking call, and this cannot be + * fixed here: a thread blocked in a JDBC driver does not respond to interruption. Pretending + * otherwise would give callers a cancellation that returns immediately while the work carries on. + * The permit is held until the call actually finishes, which is the honest accounting. + */ +public final class ControlledBlockingBridge { + + private final Scheduler scheduler; + private final BlockingBridgeBudget budget; + + /** + * @param scheduler where blocking work runs; must not be the event loop + * @param budget the registration list and the concurrency bound + */ + public ControlledBlockingBridge(Scheduler scheduler, BlockingBridgeBudget budget) { + this.scheduler = Objects.requireNonNull(scheduler, "scheduler"); + this.budget = Objects.requireNonNull(budget, "budget"); + } + + /** The budget, so a caller can read what it observed. */ + public BlockingBridgeBudget budget() { + return budget; + } + + /** + * Offload one call. + * + * @param registeredOperation the operation's registered name + * @param work the blocking call + * @param its result + */ + public Mono execute(String registeredOperation, Callable work) { + Objects.requireNonNull(registeredOperation, "registeredOperation"); + Objects.requireNonNull(work, "work"); + return Mono.fromCallable( + () -> { + // Throws before the counter moves, so a refusal never reaches the release below. + budget.acquire(registeredOperation); + try { + return work.call(); + } finally { + budget.release(); + } + }) + .subscribeOn(scheduler); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/webflux/SlowConsumerClosedException.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/webflux/SlowConsumerClosedException.java new file mode 100644 index 00000000..85717796 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/webflux/SlowConsumerClosedException.java @@ -0,0 +1,32 @@ +package dev.caskeleton.adapter.inbound.web.advanced.webflux; + +/** + * A consumer fell far enough behind that the stream was closed rather than buffered further. + * + *

The default policy, and the reason it is the default: backpressure protects the reactive + * pipeline from a fast producer, and it does nothing at all about a consumer that reads slowly for + * an hour. Whatever the pipeline holds on that consumer's behalf is held in this process's heap, + * and a hundred such consumers is a hundred backlogs. + * + *

Closing is recoverable for the client — it reconnects, with a resume cursor where the route + * supports one. Buffering is not recoverable for the server. + */ +public final class SlowConsumerClosedException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + private final transient int bufferLimit; + + public SlowConsumerClosedException(int bufferLimit) { + super( + "the consumer fell more than " + + bufferLimit + + " items behind; closing rather than holding its backlog in the server's heap"); + this.bufferLimit = bufferLimit; + } + + /** The bound that was reached. */ + public int bufferLimit() { + return bufferLimit; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/webflux/WebFluxDisconnectDetector.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/webflux/WebFluxDisconnectDetector.java new file mode 100644 index 00000000..31fa7e1b --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/webflux/WebFluxDisconnectDetector.java @@ -0,0 +1,72 @@ +package dev.caskeleton.adapter.inbound.web.advanced.webflux; + +import dev.caskeleton.adapter.inbound.web.advanced.stream.WebStreamEvidence; +import dev.caskeleton.adapter.inbound.web.advanced.stream.WebStreamTermination; +import java.time.Instant; +import java.util.Objects; +import java.util.function.Supplier; +import reactor.core.publisher.Flux; + +/** + * Records how a reactive stream ended, from the signals Reactor already provides. + * + *

The reactive stack is the easier of the two here, and it is worth saying why: a client that + * goes away produces a **cancel** signal, which arrives without anything having to be written + * first. The servlet stack has no equivalent — there the disconnect is only discovered by a write + * that throws, which is why MVC needs a heartbeat to probe for it and this does not. + * + *

The trap on this side is the opposite one. Cancellation is easy to observe and easy to *not + * propagate*: a `doOnCancel` that records the termination and nothing else leaves the upstream + * subscription live, so the source keeps producing for a client that is gone. Reactor propagates + * cancellation upstream by default, and the way to break that is to bridge through something that + * does not — a sink, an executor, a blocking call. That is what the blocking bridge's cancellation + * handling is for. + */ +public final class WebFluxDisconnectDetector { + + private WebFluxDisconnectDetector() {} + + /** + * Attach termination recording to a stream. + * + * @param source the stream + * @param evidence what to record into + * @param now supplies the current instant + * @param the element type + */ + public static Flux observe( + Flux source, WebStreamEvidence evidence, Supplier now) { + Objects.requireNonNull(source, "source"); + Objects.requireNonNull(evidence, "evidence"); + Objects.requireNonNull(now, "now"); + return source + .doOnCancel( + () -> evidence.recordTermination(WebStreamTermination.CLIENT_DISCONNECTED, now.get())) + .doOnComplete( + () -> evidence.recordTermination(WebStreamTermination.NORMAL_COMPLETE, now.get())) + .doOnError(failure -> evidence.recordTermination(classify(failure), now.get())); + } + + /** + * Classify a reactive failure. + * + *

A slow consumer that overflowed is not a server fault and not a client disconnect — it is + * the shedding bound working, and counting it as either makes the bound invisible in the metrics + * that would show it firing. + */ + public static WebStreamTermination classify(Throwable failure) { + Objects.requireNonNull(failure, "failure"); + for (Throwable current = failure; current != null; current = current.getCause()) { + if (current instanceof SlowConsumerClosedException) { + return WebStreamTermination.CLIENT_DISCONNECTED; + } + if (current instanceof java.io.IOException) { + return WebStreamTermination.CLIENT_DISCONNECTED; + } + if (current.getCause() == current) { + break; + } + } + return WebStreamTermination.TERMINAL_ERROR_RECORD; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/webflux/WebFluxSseAdapter.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/webflux/WebFluxSseAdapter.java new file mode 100644 index 00000000..64cb4db2 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/webflux/WebFluxSseAdapter.java @@ -0,0 +1,117 @@ +package dev.caskeleton.adapter.inbound.web.advanced.webflux; + +import dev.caskeleton.adapter.inbound.web.advanced.stream.StreamId; +import dev.caskeleton.adapter.inbound.web.advanced.stream.StreamSequence; +import dev.caskeleton.adapter.inbound.web.advanced.stream.WebStreamEnvelope; +import dev.caskeleton.adapter.inbound.web.advanced.stream.WebStreamPolicy; +import java.util.Objects; +import org.springframework.http.codec.ServerSentEvent; +import reactor.core.publisher.Flux; + +/** + * Turns a stream of envelopes into a bounded SSE response. + * + *

Three bounds, applied in an order that matters. + * + *

**The bound is {@code onBackpressureBuffer} followed by {@code onBackpressureError}, and both + * halves are load-bearing.** {@code onBackpressureBuffer(n)} on its own does not close anything: it + * propagates demand upstream, so a subscriber that stops requesting simply stops the source, the + * buffer never fills and the bound never fires. That is correct behaviour for a well-behaved source + * and useless as a slow-consumer policy, because the sources this carries push whether or not + * anybody asked. {@code onBackpressureError} is what turns "the subscriber is behind" into a + * terminal signal. The buffer in front of it is what stops a single slow moment from closing a + * healthy stream. + * + *

The merge prefetch is set to the same number so merge's own queue is not a second, larger + * backlog sitting behind the configured one — its default is 256, which would make a configured + * bound of 8 describe nothing. + * + *

**Overflow is an error, not a drop.** Dropping an item silently produces a gap the client + * cannot see, because the positions either side of it are both valid. Closing the connection is + * recoverable — the client reconnects, with a resume cursor where the route supports one. + * + *

**The age bound is last**, so it applies to the merged stream. A stream kept alive only by its + * own heartbeats should still reach its maximum age. + * + *

The heartbeat is infinite, so merging it would mean a finished source never completes — the + * connection would be held for the full maximum age emitting keepalives after the last item. The + * stream therefore ends at the terminal envelope rather than at the source's completion, and a + * source that completes without emitting one has a terminal envelope supplied for it. A client that + * receives neither has been cut off, and that must stay distinguishable from a clean end. + */ +public final class WebFluxSseAdapter { + + private final WebStreamPolicy policy; + + public WebFluxSseAdapter(WebStreamPolicy policy) { + this.policy = Objects.requireNonNull(policy, "policy"); + } + + /** + * Adapt a source. + * + * @param source the envelopes to deliver + * @param the item type + */ + public Flux>> adapt(Flux> source) { + Objects.requireNonNull(source, "source"); + int bound = policy.maxBufferedItems(); + java.util.concurrent.atomic.AtomicReference streamId = + new java.util.concurrent.atomic.AtomicReference<>(); + java.util.concurrent.atomic.AtomicLong lastSequence = + new java.util.concurrent.atomic.AtomicLong(); + Flux>> data = + source + .doOnNext( + envelope -> { + streamId.set(envelope.streamId()); + if (envelope instanceof WebStreamEnvelope.Item item) { + lastSequence.set(item.sequence().value()); + } + }) + // Subscribed only if the source finished without a terminal of its own, because + // takeUntil below stops at the first one it sees. + .concatWith(Flux.defer(() -> defensiveTerminal(streamId.get(), lastSequence.get()))) + .map(WebFluxSseAdapter::toEvent); + return Flux.merge(bound, data, WebFluxSseHeartbeat.>events(policy)) + .onBackpressureBuffer(bound) + .onBackpressureError() + .onErrorMap( + reactor.core.Exceptions::isOverflow, overflow -> new SlowConsumerClosedException(bound)) + .takeUntil(event -> isTerminal(event)) + .take(policy.maxStreamAge()); + } + + private static Flux> defensiveTerminal(StreamId streamId, long last) { + if (streamId == null) { + // The source produced nothing at all, so there is no stream identity to name. Completing + // silently is the only option left, and it is why a caller should emit its own terminal. + return Flux.empty(); + } + return Flux.just( + new WebStreamEnvelope.Complete<>(streamId, new StreamSequence(Math.max(last, 1)))); + } + + private static boolean isTerminal(ServerSentEvent> event) { + WebStreamEnvelope envelope = event.data(); + return envelope != null && envelope.terminal(); + } + + private static ServerSentEvent> toEvent(WebStreamEnvelope envelope) { + String name = + switch (envelope) { + case WebStreamEnvelope.Item ignored -> "item"; + case WebStreamEnvelope.Failure ignored -> "error"; + case WebStreamEnvelope.Complete ignored -> "complete"; + }; + ServerSentEvent.Builder> event = + ServerSentEvent.>builder().event(name).data(envelope); + if (envelope instanceof WebStreamEnvelope.Item item) { + // The SSE id is what the client sends back as Last-Event-ID. Setting it on items only is + // deliberate: a client that resumed from a terminal event's id would ask to continue a + // stream that had already ended. + event.id(Long.toString(item.sequence().value())); + } + return event.build(); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/webflux/WebFluxSseHeartbeat.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/webflux/WebFluxSseHeartbeat.java new file mode 100644 index 00000000..77d744ec --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/webflux/WebFluxSseHeartbeat.java @@ -0,0 +1,40 @@ +package dev.caskeleton.adapter.inbound.web.advanced.webflux; + +import dev.caskeleton.adapter.inbound.web.advanced.stream.WebStreamPolicy; +import java.time.Duration; +import java.util.Objects; +import org.springframework.http.codec.ServerSentEvent; +import reactor.core.publisher.Flux; + +/** + * A keepalive event stream to merge into a stream that may go quiet. + * + *

SSE comments would be lighter than named events, but they are invisible to {@code + * EventSource.onmessage} and to most client libraries' event handlers — so a client cannot + * distinguish "the server is alive and quiet" from "the connection is dead" without also watching + * the raw byte stream. A named event costs a few bytes and is observable by the client that needs + * it. + * + *

The interval is the policy's, and the policy refuses a heartbeat at or above the idle timeout. + * A server that times out its own healthy streams between beats is the failure that produces. + */ +public final class WebFluxSseHeartbeat { + + /** The event name the heartbeat is published under. */ + public static final String EVENT_NAME = "heartbeat"; + + private WebFluxSseHeartbeat() {} + + /** + * The keepalive stream. + * + * @param policy supplies the interval + * @param the data type of the stream it will be merged into + */ + public static Flux> events(WebStreamPolicy policy) { + Objects.requireNonNull(policy, "policy"); + Duration interval = policy.heartbeatInterval(); + return Flux.interval(interval, interval) + .map(tick -> ServerSentEvent.builder().event(EVENT_NAME).comment("keepalive").build()); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/webflux/WebFluxStreamAdmission.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/webflux/WebFluxStreamAdmission.java new file mode 100644 index 00000000..be63236a --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/webflux/WebFluxStreamAdmission.java @@ -0,0 +1,124 @@ +package dev.caskeleton.adapter.inbound.web.advanced.webflux; + +import dev.caskeleton.adapter.inbound.web.advanced.stream.StreamId; +import dev.caskeleton.adapter.inbound.web.advanced.stream.WebStreamRegistry; +import dev.caskeleton.adapter.inbound.web.advanced.stream.WebStreamSession; +import dev.caskeleton.adapter.inbound.web.advanced.stream.WebStreamTermination; +import java.time.Instant; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicBoolean; +import reactor.core.Disposable; + +/** + * Admits a reactive stream into the node's registry, and releases the slot when it ends. + * + *

Reactive streams are cheap in threads and not in everything else — each holds a connection, a + * subscription and whatever the source keeps for it. A node with ten thousand of them is idle by + * every thread metric and out of file descriptors. + * + *

The release is idempotent because Reactor can deliver more than one terminal signal to the + * handlers attached here: a cancel racing a complete is normal at the moment a client disconnects + * from a finishing stream. Releasing twice would return a slot that was already returned, and the + * registry would drift below the real count until it admitted more streams than it has capacity + * for. + */ +public final class WebFluxStreamAdmission { + + private final WebStreamRegistry registry; + + public WebFluxStreamAdmission(WebStreamRegistry registry) { + this.registry = Objects.requireNonNull(registry, "registry"); + } + + /** + * Try to admit a stream. + * + * @return the admitted session's release handle, or empty when the node is full + */ + public java.util.Optional admit( + StreamId streamId, Instant now, Disposable subscription) { + Objects.requireNonNull(streamId, "streamId"); + Objects.requireNonNull(now, "now"); + ReactiveSession session = new ReactiveSession(streamId, now, subscription); + if (!registry.register(session)) { + return java.util.Optional.empty(); + } + return java.util.Optional.of(new Admitted(session)); + } + + /** How many streams this node holds. */ + public int activeStreams() { + return registry.activeStreams(); + } + + /** A handle that returns the slot exactly once. */ + public final class Admitted { + + private final ReactiveSession session; + private final AtomicBoolean released = new AtomicBoolean(); + + private Admitted(ReactiveSession session) { + this.session = session; + } + + /** The registered session. */ + public WebStreamSession session() { + return session; + } + + /** Return the slot. Safe to call more than once; only the first call counts. */ + public void release() { + if (released.compareAndSet(false, true)) { + registry.deregister(session.streamId()); + } + } + } + + /** A registry entry backed by a Reactor subscription. */ + private static final class ReactiveSession implements WebStreamSession { + + private final StreamId streamId; + private final Instant startedAt; + private final Disposable subscription; + private final AtomicBoolean reconnectRequested = new AtomicBoolean(); + + ReactiveSession(StreamId streamId, Instant startedAt, Disposable subscription) { + this.streamId = streamId; + this.startedAt = startedAt; + this.subscription = subscription; + } + + @Override + public StreamId streamId() { + return streamId; + } + + @Override + public Instant startedAt() { + return startedAt; + } + + @Override + public void requestReconnect() { + // Recorded rather than acted on. There is no in-band way to ask a reactive SSE consumer to + // reconnect without emitting into its stream, and the drain's own terminal event is what + // does that — this flag is what lets the drain tell an asked stream from an unasked one. + reconnectRequested.set(true); + } + + @Override + public void forceClose(WebStreamTermination reason) { + Objects.requireNonNull(reason, "reason"); + if (subscription != null && !subscription.isDisposed()) { + // Disposing propagates cancellation upstream, which is the point: the source must stop + // producing for a stream nobody is reading. + subscription.dispose(); + } + } + + @Override + public boolean open() { + return subscription == null || !subscription.isDisposed(); + } + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/webflux/WebFluxStreamWriter.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/webflux/WebFluxStreamWriter.java new file mode 100644 index 00000000..f88258c1 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/advanced/webflux/WebFluxStreamWriter.java @@ -0,0 +1,70 @@ +package dev.caskeleton.adapter.inbound.web.advanced.webflux; + +import dev.caskeleton.adapter.inbound.web.advanced.stream.WebStreamPolicy; +import dev.caskeleton.adapter.inbound.web.advanced.stream.encoding.NdjsonRecord; +import dev.caskeleton.adapter.inbound.web.advanced.stream.encoding.StreamFraming; +import dev.caskeleton.adapter.inbound.web.advanced.stream.encoding.StreamRecordEncoder; +import java.util.Objects; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferFactory; +import reactor.core.publisher.Flux; + +/** + * Writes NDJSON or JSON-seq as a reactive body. + * + *

Buffers are released on cancellation and on error, not only on the happy path. A {@code + * DataBuffer} from a pooled factory is reference-counted: one that is allocated and never released + * is a leak that Netty reports as a warning nobody reads, and the symptom is a heap that grows with + * the number of streams that were cancelled rather than with the number that are open. + * `doOnDiscard` is the hook that covers the cancellation path, which is exactly the path a + * long-lived stream normally takes. + * + *

The framing itself is shared with the servlet writer. Two implementations of "one JSON value + * then a newline" would be two chances to disagree about whether the last record gets one. + */ +public final class WebFluxStreamWriter { + + private final StreamRecordEncoder encoder; + private final StreamFraming framing; + private final WebStreamPolicy policy; + + public WebFluxStreamWriter( + StreamRecordEncoder encoder, StreamFraming framing, WebStreamPolicy policy) { + this.encoder = Objects.requireNonNull(encoder, "encoder"); + this.framing = Objects.requireNonNull(framing, "framing"); + this.policy = Objects.requireNonNull(policy, "policy"); + } + + /** The media type to set on the response. */ + public String mediaType() { + return framing.mediaType(); + } + + /** + * Write a stream of records as buffers. + * + * @param records the source, terminal record included + * @param buffers the response's buffer factory + * @param the item type + */ + public Flux write(Flux> records, DataBufferFactory buffers) { + Objects.requireNonNull(records, "records"); + Objects.requireNonNull(buffers, "buffers"); + return records + .onBackpressureBuffer(policy.maxBufferedItems()) + // The pair, not the buffer alone: onBackpressureBuffer propagates demand and never closes + // anything by itself, so a subscriber that stops requesting would just stop the source. + .onBackpressureError() + .onErrorMap( + reactor.core.Exceptions::isOverflow, + overflow -> new SlowConsumerClosedException(policy.maxBufferedItems())) + .map(record -> buffers.wrap(encode(record))) + // Covers cancellation, which for a long-lived stream is how it usually ends. Without it the + // heap grows with the number of streams that were cancelled. + .doOnDiscard(DataBuffer.class, org.springframework.core.io.buffer.DataBufferUtils::release); + } + + private byte[] encode(Object record) { + return framing == StreamFraming.NDJSON ? encoder.ndjson(record) : encoder.jsonSequence(record); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/budget/WebBudgetCatalog.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/budget/WebBudgetCatalog.java new file mode 100644 index 00000000..7300a774 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/budget/WebBudgetCatalog.java @@ -0,0 +1,77 @@ +package dev.caskeleton.adapter.inbound.web.budget; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +/** + * The registered budget profiles a deployment offers, and the rule that an override may only + * tighten. + * + *

Registration is fail-closed in both directions. A duplicate name is refused, because two + * profiles called {@code standard} means whichever registered last silently wins and the review + * that approved the other one described nothing. A route override wider than the profile it + * overrides is refused for the same reason the profile has bounds at all: an override is meant to + * be a local tightening, and a widening one is how a single route quietly reintroduces the limit + * the platform removed. + */ +public final class WebBudgetCatalog { + + private final Map profiles = new ConcurrentHashMap<>(); + + /** + * Registers a profile. + * + * @throws IllegalStateException when the name is already registered + */ + public void register(WebBudgetProfileName name, WebRequestBudget budget) { + Objects.requireNonNull(name, "name"); + Objects.requireNonNull(budget, "budget"); + if (profiles.putIfAbsent(name, budget) != null) { + throw new IllegalStateException("duplicate budget profile: " + name); + } + } + + /** + * Registers a route-scoped override of an existing profile. + * + * @throws IllegalArgumentException when the base profile is unknown or the override is wider + */ + public void registerOverride( + WebBudgetProfileName name, WebBudgetProfileName base, WebRequestBudget override) { + Objects.requireNonNull(override, "override"); + WebRequestBudget parent = require(base); + if (!override.noWiderThan(parent)) { + throw new IllegalArgumentException( + "budget override " + name + " is wider than its base profile " + base); + } + register(name, override); + } + + /** + * The budget for a profile. + * + * @throws IllegalArgumentException when the profile was never registered + */ + public WebRequestBudget require(WebBudgetProfileName name) { + Objects.requireNonNull(name, "name"); + WebRequestBudget budget = profiles.get(name); + if (budget == null) { + throw new IllegalArgumentException( + "unknown budget profile: " + name + "; registered: " + profiles.keySet()); + } + return budget; + } + + /** The budget for a profile, when it is registered. */ + public Optional find(WebBudgetProfileName name) { + return Optional.ofNullable(profiles.get(name == null ? null : name)); + } + + /** Every registered profile, for a startup report. */ + public Map registered() { + return Map.copyOf(new LinkedHashMap<>(profiles)); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/budget/WebBudgetExceededException.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/budget/WebBudgetExceededException.java new file mode 100644 index 00000000..7c429e0e --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/budget/WebBudgetExceededException.java @@ -0,0 +1,56 @@ +package dev.caskeleton.adapter.inbound.web.budget; + +import java.util.Objects; + +/** + * A request or response crossed a declared bound. + * + *

Carries which bound, because the answer differs: an oversized body is 413 and the caller can + * fix it, an execution overrun is 503 and the caller cannot, and a response that grew past its + * bound is the platform's own fault. A single "too big" exception would have the error mapper + * guess, and it would guess 413 for all three. + * + * @see WebBudgetViolation + */ +public final class WebBudgetExceededException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + private final transient WebBudgetViolation violation; + private final long observed; + private final long allowed; + + /** + * A crossed bound. + * + * @param violation which bound + * @param observed what was measured + * @param allowed what the profile permits + */ + public WebBudgetExceededException(WebBudgetViolation violation, long observed, long allowed) { + super( + Objects.requireNonNull(violation, "violation") + + ": observed " + + observed + + ", allowed " + + allowed); + this.violation = violation; + this.observed = observed; + this.allowed = allowed; + } + + /** Which bound was crossed. */ + public WebBudgetViolation violation() { + return violation; + } + + /** What was measured. */ + public long observed() { + return observed; + } + + /** What the profile permits. */ + public long allowed() { + return allowed; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/budget/WebBudgetMeter.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/budget/WebBudgetMeter.java new file mode 100644 index 00000000..219b3382 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/budget/WebBudgetMeter.java @@ -0,0 +1,89 @@ +package dev.caskeleton.adapter.inbound.web.budget; + +import java.util.Objects; +import java.util.concurrent.atomic.AtomicLong; + +/** + * A running byte count that fails the moment it crosses its bound. + * + *

Counting as bytes arrive, rather than measuring what arrived, is the whole point. Reading a + * body into memory to check its size means a caller can exhaust the heap with a request the + * platform was going to reject anyway — the check becomes the vulnerability it was added to + * prevent. The same applies on the way out: a response is refused as it is written, not after it + * has been assembled. + * + *

{@code AtomicLong} because a reactive response is written from whichever thread the pipeline + * is on, and two writes racing on a plain {@code long} would both read a total below the bound. + */ +public final class WebBudgetMeter { + + private final WebBudgetViolation violation; + private final long limit; + private final AtomicLong counted = new AtomicLong(); + + /** + * A meter for one dimension. + * + * @param violation what to report when the bound is crossed + * @param limit the bound + */ + public WebBudgetMeter(WebBudgetViolation violation, long limit) { + this.violation = Objects.requireNonNull(violation, "violation"); + if (limit <= 0) { + throw new IllegalArgumentException("a budget of " + limit + " bytes admits nothing"); + } + this.limit = limit; + } + + /** + * Counts bytes and refuses the moment the bound is crossed. + * + * @param byteCount how many bytes just moved + * @throws WebBudgetExceededException when the running total exceeds the bound + */ + public void add(long byteCount) { + if (byteCount <= 0) { + return; + } + long total = counted.addAndGet(byteCount); + if (total > limit) { + throw new WebBudgetExceededException(violation, total, limit); + } + } + + /** + * Refuses a declared size before a single byte is read. + * + *

{@code Content-Length} is a claim, not a measurement, so it is checked *as well as* the + * running count and never instead of it. Believing it alone would let a chunked request, or a + * lying one, walk straight past the bound. + * + * @param declared the value the caller declared, or -1 when it declared none + */ + public void declared(long declared) { + if (declared > limit) { + throw new WebBudgetExceededException(violation, declared, limit); + } + } + + /** + * Forgets everything counted so far. + * + *

Called when a response buffer is discarded. Those bytes never left the server, so a meter + * that kept counting them would refuse the small error document that is about to replace them — + * and the caller would get the container's own error page instead of the platform's. + */ + public void reset() { + counted.set(0); + } + + /** How many bytes have moved. */ + public long counted() { + return counted.get(); + } + + /** The bound. */ + public long limit() { + return limit; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/budget/WebBudgetOutcome.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/budget/WebBudgetOutcome.java new file mode 100644 index 00000000..df6334d8 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/budget/WebBudgetOutcome.java @@ -0,0 +1,40 @@ +package dev.caskeleton.adapter.inbound.web.budget; + +/** + * The message a crossed bound is published with. + * + *

Only the message. An earlier draft also had a violation-to-status table here, and it was wrong + * within a day: {@code ProblemCatalog} already pins a status to every code, this table said + * something different for two of them, and {@code requireStatusAgreement} refused the response — + * turning a 400 into a 500. The status now comes from the catalog by way of the code, so there is + * one table and nothing to keep in sync. + * + *

What is left is genuinely this module's: a budget is arithmetic, and its message should not + * need a catalog to be testable. + */ +public final class WebBudgetOutcome { + + private WebBudgetOutcome() {} + + /** + * A message safe to publish. + * + *

The limit is included and the observed value is not. The limit is public API — a client + * needs it to comply — while the observed value can restate content the caller sent, and echoing + * a request back is how a size error becomes a reflection gadget. + */ + public static String detailFor(WebBudgetViolation violation, long allowed) { + return switch (violation) { + case URI_TOO_LONG -> "the request URI exceeds " + allowed + " bytes"; + case HEADERS_TOO_LARGE -> "the request headers exceed " + allowed + " bytes"; + case BODY_TOO_LARGE -> "the request body exceeds " + allowed + " bytes"; + case TOO_MANY_QUERY_PARAMETERS -> + "the request carries more than " + allowed + " query parameters"; + case JSON_TOO_DEEP -> "the request body nests deeper than " + allowed + " levels"; + case ARRAY_TOO_LARGE -> "an array in the request body exceeds " + allowed + " elements"; + case TOO_MANY_MULTIPART_PARTS -> "the request carries more than " + allowed + " parts"; + case EXECUTION_TIME_EXCEEDED -> "the operation exceeded its time budget"; + case RESPONSE_TOO_LARGE -> "the response exceeded its size budget"; + }; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/budget/WebBudgetProfileName.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/budget/WebBudgetProfileName.java new file mode 100644 index 00000000..65a4d2c3 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/budget/WebBudgetProfileName.java @@ -0,0 +1,35 @@ +package dev.caskeleton.adapter.inbound.web.budget; + +import java.util.regex.Pattern; + +/** + * The name of a registered request budget profile. + * + *

A name rather than the budget itself, because an operation profile is a declaration and a + * budget is a deployment decision. Naming the profile lets a deployment tighten every "bulk-write" + * operation at once without editing the operations, and lets a startup check report which profile + * is missing instead of failing on a null limit at the first large request. + * + * @param value the profile name, matching {@code [a-z][a-z0-9.-]{2,63}} + */ +public record WebBudgetProfileName(String value) { + + private static final Pattern GRAMMAR = Pattern.compile("[a-z][a-z0-9.-]{2,63}"); + + public WebBudgetProfileName { + if (value == null || !GRAMMAR.matcher(value).matches()) { + throw new IllegalArgumentException( + "budget profile name must match [a-z][a-z0-9.-]{2,63}, was: " + value); + } + } + + /** The profile every operation falls back to when it names none. */ + public static WebBudgetProfileName standard() { + return new WebBudgetProfileName("standard"); + } + + @Override + public String toString() { + return value; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/budget/WebBudgetViolation.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/budget/WebBudgetViolation.java new file mode 100644 index 00000000..5ccabea1 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/budget/WebBudgetViolation.java @@ -0,0 +1,39 @@ +package dev.caskeleton.adapter.inbound.web.budget; + +/** + * Which bound a request exceeded, named so the response can be honest without being useful to an + * attacker. + * + *

The axis is reported; the numbers are not. Telling a caller "the body limit is 262144 bytes" + * turns probing into a single request, and the caller who legitimately hit the limit does not need + * the exact figure to know they sent too much. + */ +public enum WebBudgetViolation { + + /** The request URI was longer than the profile allows. */ + URI_TOO_LONG, + + /** The header block was larger than the profile allows. */ + HEADERS_TOO_LARGE, + + /** More query parameters than the profile allows. */ + TOO_MANY_QUERY_PARAMETERS, + + /** The request body was larger than the profile allows. */ + BODY_TOO_LARGE, + + /** The JSON document nested deeper than the profile allows. */ + JSON_TOO_DEEP, + + /** A JSON array held more elements than the profile allows. */ + ARRAY_TOO_LARGE, + + /** The multipart body held more parts than the profile allows. */ + TOO_MANY_MULTIPART_PARTS, + + /** The operation ran past its deadline. */ + EXECUTION_TIME_EXCEEDED, + + /** The response would be larger than the profile allows. */ + RESPONSE_TOO_LARGE +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/budget/WebRequestBudget.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/budget/WebRequestBudget.java new file mode 100644 index 00000000..476086f9 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/budget/WebRequestBudget.java @@ -0,0 +1,141 @@ +package dev.caskeleton.adapter.inbound.web.budget; + +import java.time.Duration; + +/** + * Every hard bound one operation's request and response must respect. + * + *

All nine limits in one value on purpose. A budget spread across nine settings is a budget with + * eight defaults nobody chose: the deployment tightens the body size, the JSON depth stays at + * whatever the parser ships with, and the request that exhausts the heap is a small body containing + * a deeply nested array. Grouping them means a profile is reviewed as a whole. + * + *

Each bound is checked against a platform maximum as well as against zero. A per-route override + * may only make a limit smaller, which is enforced by {@link #withinPlatformMaximum()} being + * applied at construction rather than by asking callers to remember. + * + * @param maxUriBytes longest accepted request URI + * @param maxHeaderBytes largest accepted total header block + * @param maxQueryParameters most query parameters accepted + * @param maxBodyBytes largest accepted request body + * @param maxJsonDepth deepest accepted JSON nesting + * @param maxArrayElements most elements accepted in one JSON array + * @param maxMultipartParts most parts accepted in one multipart body + * @param maxExecutionTime longest this operation may run before its deadline expires + * @param maxResponseBytes largest response this operation may produce + */ +public record WebRequestBudget( + int maxUriBytes, + int maxHeaderBytes, + int maxQueryParameters, + long maxBodyBytes, + int maxJsonDepth, + int maxArrayElements, + int maxMultipartParts, + Duration maxExecutionTime, + long maxResponseBytes) { + + /** The largest body any profile may accept, whatever a route asks for. */ + public static final long ABSOLUTE_BODY_MAX = 8L * 1024 * 1024; + + /** The largest response any profile may produce. */ + public static final long ABSOLUTE_RESPONSE_MAX = 32L * 1024 * 1024; + + /** The longest any operation may run; beyond this a client has already given up. */ + public static final Duration ABSOLUTE_EXECUTION_MAX = Duration.ofMinutes(2); + + private static final int ABSOLUTE_URI_MAX = 8 * 1024; + private static final int ABSOLUTE_HEADER_MAX = 64 * 1024; + private static final int ABSOLUTE_QUERY_PARAMETERS_MAX = 512; + private static final int ABSOLUTE_JSON_DEPTH_MAX = 128; + private static final int ABSOLUTE_ARRAY_ELEMENTS_MAX = 100_000; + private static final int ABSOLUTE_MULTIPART_PARTS_MAX = 256; + + public WebRequestBudget { + requirePositive(maxUriBytes, "maxUriBytes"); + requirePositive(maxHeaderBytes, "maxHeaderBytes"); + requirePositive(maxQueryParameters, "maxQueryParameters"); + requirePositive(maxJsonDepth, "maxJsonDepth"); + requirePositive(maxArrayElements, "maxArrayElements"); + requirePositive(maxMultipartParts, "maxMultipartParts"); + if (maxBodyBytes <= 0 || maxBodyBytes > ABSOLUTE_BODY_MAX) { + throw new IllegalArgumentException( + "body limit must be positive and at most " + ABSOLUTE_BODY_MAX + ", was " + maxBodyBytes); + } + if (maxResponseBytes <= 0 || maxResponseBytes > ABSOLUTE_RESPONSE_MAX) { + throw new IllegalArgumentException( + "response limit must be positive and at most " + + ABSOLUTE_RESPONSE_MAX + + ", was " + + maxResponseBytes); + } + // A profile with no execution limit is the one that takes the container down: the request that + // never finishes holds a worker, and enough of them hold every worker. + if (maxExecutionTime == null || maxExecutionTime.isZero() || maxExecutionTime.isNegative()) { + throw new IllegalArgumentException("execution time limit is required and must be positive"); + } + if (maxExecutionTime.compareTo(ABSOLUTE_EXECUTION_MAX) > 0) { + throw new IllegalArgumentException( + "execution time limit exceeds the platform maximum of " + ABSOLUTE_EXECUTION_MAX); + } + requireAtMost(maxUriBytes, ABSOLUTE_URI_MAX, "maxUriBytes"); + requireAtMost(maxHeaderBytes, ABSOLUTE_HEADER_MAX, "maxHeaderBytes"); + requireAtMost(maxQueryParameters, ABSOLUTE_QUERY_PARAMETERS_MAX, "maxQueryParameters"); + requireAtMost(maxJsonDepth, ABSOLUTE_JSON_DEPTH_MAX, "maxJsonDepth"); + requireAtMost(maxArrayElements, ABSOLUTE_ARRAY_ELEMENTS_MAX, "maxArrayElements"); + requireAtMost(maxMultipartParts, ABSOLUTE_MULTIPART_PARTS_MAX, "maxMultipartParts"); + } + + /** The conservative defaults a deployment that has not thought about this gets. */ + public static WebRequestBudget standard() { + return new WebRequestBudget( + 2048, 8192, 64, 256L * 1024, 32, 1_000, 16, Duration.ofSeconds(10), 4L * 1024 * 1024); + } + + /** + * Whether this budget is no wider than another on every axis. + * + *

Used for the route-override rule: an override may tighten a global limit and may never widen + * one. Checking every axis rather than the one the caller changed is deliberate — an override + * that shrinks the body while doubling the array count has widened the budget. + */ + public boolean withinPlatformMaximum() { + return noWiderThan( + new WebRequestBudget( + ABSOLUTE_URI_MAX, + ABSOLUTE_HEADER_MAX, + ABSOLUTE_QUERY_PARAMETERS_MAX, + ABSOLUTE_BODY_MAX, + ABSOLUTE_JSON_DEPTH_MAX, + ABSOLUTE_ARRAY_ELEMENTS_MAX, + ABSOLUTE_MULTIPART_PARTS_MAX, + ABSOLUTE_EXECUTION_MAX, + ABSOLUTE_RESPONSE_MAX)); + } + + /** Whether this budget is no wider than {@code other} on every axis. */ + public boolean noWiderThan(WebRequestBudget other) { + return maxUriBytes <= other.maxUriBytes + && maxHeaderBytes <= other.maxHeaderBytes + && maxQueryParameters <= other.maxQueryParameters + && maxBodyBytes <= other.maxBodyBytes + && maxJsonDepth <= other.maxJsonDepth + && maxArrayElements <= other.maxArrayElements + && maxMultipartParts <= other.maxMultipartParts + && maxExecutionTime.compareTo(other.maxExecutionTime) <= 0 + && maxResponseBytes <= other.maxResponseBytes; + } + + private static void requirePositive(int value, String name) { + if (value <= 0) { + throw new IllegalArgumentException(name + " must be positive, was " + value); + } + } + + private static void requireAtMost(int value, int maximum, String name) { + if (value > maximum) { + throw new IllegalArgumentException( + name + " exceeds the platform maximum of " + maximum + ", was " + value); + } + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/cache/WebCacheHeaders.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/cache/WebCacheHeaders.java new file mode 100644 index 00000000..b71cd1d4 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/cache/WebCacheHeaders.java @@ -0,0 +1,28 @@ +package dev.caskeleton.adapter.inbound.web.cache; + +import java.util.Objects; +import java.util.Optional; + +/** + * The cache headers one policy produces. + * + *

Rendered once, here, rather than assembled per controller. {@code Cache-Control} is a + * directive list whose meaning changes with what is and is not present, and a response that + * assembles it by appending strings ends up with combinations nobody chose — {@code no-cache} and + * {@code max-age} together, or {@code public} on a response that also says {@code no-store}. + * + * @param cacheControl the {@code Cache-Control} value + * @param vary the {@code Vary} value, or empty when nothing varies + */ +public record WebCacheHeaders(String cacheControl, Optional vary) { + + public WebCacheHeaders { + Objects.requireNonNull(cacheControl, "cacheControl"); + Objects.requireNonNull(vary, "vary"); + if (cacheControl.isBlank()) { + throw new IllegalArgumentException( + "a blank Cache-Control is not a policy; it leaves every cache to guess, and they guess" + + " differently"); + } + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/cache/WebCachePolicy.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/cache/WebCachePolicy.java new file mode 100644 index 00000000..31b21401 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/cache/WebCachePolicy.java @@ -0,0 +1,72 @@ +package dev.caskeleton.adapter.inbound.web.cache; + +import java.time.Duration; +import java.util.Objects; +import java.util.Optional; + +/** + * One named caching decision. + * + *

Named, because "how long may this be cached" is a decision somebody has to make on purpose. A + * controller that sets its own headers makes it implicitly, differently each time, and usually only + * for the endpoints whose author happened to think about it — leaving the rest to whatever a proxy + * decides in the absence of any directive, which is not nothing. + * + *

The constructor refuses the combinations that are self-contradictory or unsafe. The one worth + * naming is {@code sharedCacheAllowed} on a response that requires authorization: a shared cache + * storing it will serve it to the next caller who asks for the same URL, and that caller is not the + * one who authenticated. + * + * @param name the profile's identity + * @param cacheControl the rendered directive list + * @param vary which request headers change the representation + * @param sharedCacheAllowed whether a proxy may store it + * @param freshness how long it stays fresh, when it does + */ +public record WebCachePolicy( + String name, + String cacheControl, + WebVaryPolicy vary, + boolean sharedCacheAllowed, + Optional freshness) { + + public WebCachePolicy { + Objects.requireNonNull(name, "name"); + Objects.requireNonNull(cacheControl, "cacheControl"); + Objects.requireNonNull(vary, "vary"); + Objects.requireNonNull(freshness, "freshness"); + if (name.isBlank()) { + throw new IllegalArgumentException("a cache policy needs a name to be referenced by"); + } + if (cacheControl.contains("no-store") && freshness.isPresent()) { + throw new IllegalArgumentException( + "no-store and a freshness lifetime contradict each other: one says never keep this, the" + + " other says how long to keep it"); + } + if (cacheControl.contains("public") && !sharedCacheAllowed) { + throw new IllegalArgumentException( + "a policy marked public is telling shared caches to store it; sharedCacheAllowed must" + + " say the same thing"); + } + if (cacheControl.contains("private") && sharedCacheAllowed) { + throw new IllegalArgumentException( + "a policy marked private forbids shared caches; sharedCacheAllowed must say the same"); + } + } + + /** The headers this policy produces. */ + public WebCacheHeaders headers() { + return new WebCacheHeaders(cacheControl, Optional.ofNullable(vary.headerValue())); + } + + /** + * Whether this policy may be applied to a response that required authorization. + * + *

The check the design asks for. Storing an authorized response in a shared cache is + * occasionally right — a catalogue behind a login that is identical for everyone — but it is + * never right by default, so it takes a profile that says so explicitly. + */ + public boolean safeForAuthorizedResponse() { + return !sharedCacheAllowed; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/cache/WebCachePolicyCatalog.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/cache/WebCachePolicyCatalog.java new file mode 100644 index 00000000..b09aadbd --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/cache/WebCachePolicyCatalog.java @@ -0,0 +1,120 @@ +package dev.caskeleton.adapter.inbound.web.cache; + +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; + +/** + * The cache profiles this API publishes. + * + *

A closed catalog rather than free-form headers, for the same reason {@code ProblemCode} is + * closed: the set of caching decisions an API makes is small, and each one is a review. Four + * profiles cover what a JSON API actually needs, and an endpoint that fits none of them is an + * endpoint whose caching nobody has thought about yet. + */ +public final class WebCachePolicyCatalog { + + private final Map profiles; + + private WebCachePolicyCatalog(Map profiles) { + this.profiles = profiles; + } + + /** + * Anything a caller must not find in a cache: personal data, tokens, one-time values. + * + *

{@code no-store}, not {@code no-cache}. They are routinely confused and mean opposite + * things: {@code no-cache} permits storing the response and requires revalidating before reuse, + * so the document does sit in the cache and on the disk behind it. {@code no-store} is the one + * that says do not write this down. + */ + public static WebCachePolicy sensitive() { + return new WebCachePolicy( + "sensitive", "private, no-store", WebVaryPolicy.none(), false, Optional.empty()); + } + + /** + * Content addressed by a digest or a version, which by construction cannot change. + * + *

{@code immutable} suppresses the revalidation a browser would otherwise do on reload. It is + * only honest for a URL whose content genuinely cannot change; on anything else it strands + * clients on a stale copy for a year with no way to invalidate it. + */ + public static WebCachePolicy immutableAsset() { + return new WebCachePolicy( + "immutable-asset", + "public, max-age=31536000, immutable", + WebVaryPolicy.varyingOn(Set.of("accept-encoding")), + true, + Optional.of(Duration.ofDays(365))); + } + + /** + * Shared content that changes, served with a validator. + * + *

{@code max-age=0, must-revalidate} rather than {@code no-cache}: they behave the same way + * for a compliant cache, and this spelling keeps the entry storable and revalidatable, which is + * what makes a 304 possible at all. + */ + public static WebCachePolicy revalidated() { + return new WebCachePolicy( + "revalidated", + "public, max-age=0, must-revalidate", + WebVaryPolicy.varyingOn(Set.of("accept", "accept-encoding", "accept-language")), + true, + Optional.of(Duration.ZERO)); + } + + /** + * A per-caller response a browser may keep briefly but no proxy may store. + * + *

The right default for an authenticated read: the caller's own back button works, and nothing + * between them and the server keeps a copy. + */ + public static WebCachePolicy browserPrivate() { + return new WebCachePolicy( + "browser-private", + "private, max-age=30", + WebVaryPolicy.varyingOn(Set.of("accept", "accept-language")), + false, + Optional.ofNullable(Duration.ofSeconds(30))); + } + + /** The four published profiles. */ + public static WebCachePolicyCatalog standard() { + Map profiles = new LinkedHashMap<>(); + for (WebCachePolicy policy : + new WebCachePolicy[] {sensitive(), immutableAsset(), revalidated(), browserPrivate()}) { + profiles.put(policy.name(), policy); + } + return new WebCachePolicyCatalog(Map.copyOf(profiles)); + } + + /** + * The named profile. + * + * @throws IllegalArgumentException when no profile has that name + */ + public WebCachePolicy require(String name) { + Objects.requireNonNull(name, "name"); + WebCachePolicy policy = profiles.get(name); + if (policy == null) { + throw new IllegalArgumentException( + "no cache profile named " + + name + + "; the published set is " + + profiles.keySet() + + ", and an endpoint that fits none of them needs a reviewed profile, not" + + " hand-written headers"); + } + return policy; + } + + /** Every published profile. */ + public Map registered() { + return profiles; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/cache/WebVaryPolicy.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/cache/WebVaryPolicy.java new file mode 100644 index 00000000..19c1f5e8 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/cache/WebVaryPolicy.java @@ -0,0 +1,90 @@ +package dev.caskeleton.adapter.inbound.web.cache; + +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.Set; +import java.util.SortedSet; +import java.util.TreeSet; + +/** + * Which request headers a cache must key on. + * + *

{@code Vary} is one of the few headers where both mistakes are expensive and neither is + * visible in testing. Omit a header that changes the representation and a shared cache serves one + * caller's document to another — a German page to an English reader, or worse, one tenant's data to + * another. List a header that does not change it, or one that is effectively unique per client, and + * the cache stops hitting entirely while still paying to store everything. + * + *

So the set is closed. A header is admitted only if the platform can say what it changes, and + * the ones that are always wrong are refused by name rather than by review. + */ +public final class WebVaryPolicy { + + /** + * Headers that must never appear in {@code Vary}. + * + *

Each is effectively unique per client, so keying on it gives every caller a private cache + * entry — all of the storage cost of caching and none of the sharing. + */ + private static final Set NEVER_VARY = + Set.of("user-agent", "cookie", "referer", "authorization", "date", "host"); + + // Ordered as well as unique. `Vary` order carries no meaning to a cache, but a header whose + // text changes between two servers serving the same route is a difference an operator has to + // chase, and a set built from Set.of() renders in whatever order the hash happened to produce. + private final List ordered; + private final Set membership; + + private WebVaryPolicy(List ordered) { + this.ordered = ordered; + this.membership = Set.copyOf(ordered); + } + + /** A policy that varies on nothing. */ + public static WebVaryPolicy none() { + return new WebVaryPolicy(List.of()); + } + + /** + * A policy over the named headers. + * + * @param headerNames the headers that change the representation + * @throws IllegalArgumentException when a header is one that must never be varied on + */ + public static WebVaryPolicy varyingOn(Set headerNames) { + Objects.requireNonNull(headerNames, "headerNames"); + SortedSet normalized = new TreeSet<>(); + for (String name : headerNames) { + Objects.requireNonNull(name, "header name"); + String lower = name.toLowerCase(Locale.ROOT); + if (NEVER_VARY.contains(lower)) { + throw new IllegalArgumentException( + "Vary must not include " + + name + + ": it is effectively unique per client, so every caller would get a private" + + " cache entry and the cache would stop being one"); + } + if (lower.isBlank()) { + throw new IllegalArgumentException("a blank header name is not a header"); + } + normalized.add(lower); + } + return new WebVaryPolicy(List.copyOf(normalized)); + } + + /** The headers, lowercased. */ + public Set headerNames() { + return membership; + } + + /** The {@code Vary} header value, or null when nothing varies. */ + public String headerValue() { + return ordered.isEmpty() ? null : String.join(", ", ordered); + } + + /** Whether a header is one that may never be varied on. */ + public static boolean forbidden(String headerName) { + return headerName != null && NEVER_VARY.contains(headerName.toLowerCase(Locale.ROOT)); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/conditional/ConditionalReadDecision.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/conditional/ConditionalReadDecision.java new file mode 100644 index 00000000..a5bfb5ad --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/conditional/ConditionalReadDecision.java @@ -0,0 +1,17 @@ +package dev.caskeleton.adapter.inbound.web.conditional; + +/** + * What a conditional read should answer. + * + *

Two outcomes, and the platform never invents a third. A validator that matches means the + * client already holds the representation and the response is a 304 with no body; anything else is + * the full representation. There is no "probably unchanged". + */ +public enum ConditionalReadDecision { + + /** The client's validator matched: answer 304 and write no body. */ + NOT_MODIFIED, + + /** No validator matched: answer the full representation. */ + SERVE_REPRESENTATION +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/conditional/ConditionalReadEvaluator.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/conditional/ConditionalReadEvaluator.java new file mode 100644 index 00000000..de3d8495 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/conditional/ConditionalReadEvaluator.java @@ -0,0 +1,69 @@ +package dev.caskeleton.adapter.inbound.web.conditional; + +import java.util.List; +import java.util.Objects; + +/** + * Decides whether a {@code GET} or {@code HEAD} may be answered with a 304. + * + *

Weak comparison, per RFC 9110. A read is asking "is what I hold still good enough to render", + * and a weak validator answers that: two representations that differ only in whitespace are the + * same to a reader. Using strong comparison here would send a full body for every cosmetic + * difference, which is the caching bug that makes people turn conditional requests off. + * + *

{@code If-None-Match: *} matches whenever a representation exists at all, which is what makes + * it useful on a read of a resource whose validator the client has never seen. + */ +public final class ConditionalReadEvaluator { + + /** + * Evaluates a conditional read. + * + * @param ifNoneMatchHeader the raw {@code If-None-Match} header, or null + * @param currentTag the resource's current validator, or null when it has none + */ + public ConditionalReadDecision evaluate(String ifNoneMatchHeader, EntityTag currentTag) { + if (ifNoneMatchHeader == null || ifNoneMatchHeader.isBlank()) { + return ConditionalReadDecision.SERVE_REPRESENTATION; + } + if (EntityTagCodec.isWildcard(ifNoneMatchHeader)) { + return currentTag == null + ? ConditionalReadDecision.SERVE_REPRESENTATION + : ConditionalReadDecision.NOT_MODIFIED; + } + if (currentTag == null) { + return ConditionalReadDecision.SERVE_REPRESENTATION; + } + List candidates = EntityTagCodec.parseList(ifNoneMatchHeader); + boolean matched = candidates.stream().anyMatch(currentTag::matchesWeakly); + return matched + ? ConditionalReadDecision.NOT_MODIFIED + : ConditionalReadDecision.SERVE_REPRESENTATION; + } + + /** + * Whether a 304 may be answered. + * + * @param ifNoneMatchHeader the raw header + * @param currentTag the current validator + */ + public boolean notModified(String ifNoneMatchHeader, EntityTag currentTag) { + return evaluate(ifNoneMatchHeader, currentTag) == ConditionalReadDecision.NOT_MODIFIED; + } + + /** + * The validator a response carries. + * + *

Separate from the application's own version so the two cannot be assumed to be the same + * type. A storage ETag, a provider ETag and an optimistic-lock revision are all validators of + * something, and treating one as interchangeable with another is how a client's conditional write + * is compared against a value the resource does not have. + * + * @param applicationVersion the application's own version marker + * @param strong whether the mapping is byte-exact + */ + public EntityTag toEntityTag(String applicationVersion, boolean strong) { + Objects.requireNonNull(applicationVersion, "applicationVersion"); + return strong ? EntityTag.strong(applicationVersion) : EntityTag.weak(applicationVersion); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/conditional/EntityTag.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/conditional/EntityTag.java new file mode 100644 index 00000000..0b35c91c --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/conditional/EntityTag.java @@ -0,0 +1,83 @@ +package dev.caskeleton.adapter.inbound.web.conditional; + +import java.util.Objects; + +/** + * One entity tag, and whether it is strong or weak. + * + *

The distinction is not decorative. A weak validator says "semantically equivalent" — a + * response whose whitespace or compression differs may share one — so it is sound for a cached read + * and unsound for a conditional write. Using a weak tag for {@code If-Match} means two writers + * whose representations differ only cosmetically both pass the precondition, and the second + * overwrites the first. + * + *

The value excludes the quotes and the {@code W/} prefix; those are wire syntax, and keeping + * them in the value is how a comparison ends up matching {@code "v1"} against {@code v1}. + * + * @param value the opaque validator, without quotes + * @param weak whether this is a weak validator + */ +public record EntityTag(String value, boolean weak) { + + public EntityTag { + Objects.requireNonNull(value, "value"); + if (value.isBlank()) { + throw new IllegalArgumentException("an entity tag needs a value"); + } + if (value.length() > 256) { + throw new IllegalArgumentException("entity tag is longer than any validator needs to be"); + } + if (value.indexOf('"') >= 0) { + throw new IllegalArgumentException( + "an entity tag value excludes its quotes; keeping them is how a comparison matches" + + " the wrong thing"); + } + for (int index = 0; index < value.length(); index++) { + char character = value.charAt(index); + if (character < 0x21 || character == 0x7F) { + throw new IllegalArgumentException("entity tag carries a character the wire cannot hold"); + } + } + } + + /** + * A strong validator: byte-for-byte identity, and the only kind a write may be conditioned on. + */ + public static EntityTag strong(String value) { + return new EntityTag(value, false); + } + + /** A weak validator: semantic equivalence, sound for a cached read. */ + public static EntityTag weak(String value) { + return new EntityTag(value, true); + } + + /** The wire form, with quotes and the weakness prefix. */ + public String toHeaderValue() { + return (weak ? "W/" : "") + '"' + value + '"'; + } + + /** + * Whether this tag matches another strongly. + * + *

Strong comparison: both must be strong and the values equal. Required for {@code If-Match}. + */ + public boolean matchesStrongly(EntityTag other) { + return other != null && !weak && !other.weak && value.equals(other.value); + } + + /** + * Whether this tag matches another weakly. + * + *

Weak comparison: the values are equal whatever their strength. Sufficient for {@code + * If-None-Match} on a read. + */ + public boolean matchesWeakly(EntityTag other) { + return other != null && value.equals(other.value); + } + + @Override + public String toString() { + return toHeaderValue(); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/conditional/EntityTagCodec.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/conditional/EntityTagCodec.java new file mode 100644 index 00000000..331e6345 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/conditional/EntityTagCodec.java @@ -0,0 +1,72 @@ +package dev.caskeleton.adapter.inbound.web.conditional; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +/** + * Reads and writes the {@code ETag}, {@code If-Match} and {@code If-None-Match} header syntax. + * + *

Parsing is strict and returns nothing rather than guessing. A malformed validator that is + * treated as "no validator" turns a conditional request into an unconditional one, which is how a + * lost update happens with the precondition header present in the request log. + * + *

{@code *} is a distinct answer rather than a tag, because it means "any current + * representation" and no comparison against a value can express that. + */ +public final class EntityTagCodec { + + /** The wildcard, which matches any existing representation. */ + public static final String WILDCARD = "*"; + + private EntityTagCodec() {} + + /** + * Parses a single {@code ETag} header value. + * + * @param headerValue the raw header, or null + * @return the tag, or empty when the header is absent or malformed + */ + public static Optional parseSingle(String headerValue) { + if (headerValue == null) { + return Optional.empty(); + } + String candidate = headerValue.trim(); + boolean weak = false; + if (candidate.startsWith("W/")) { + weak = true; + candidate = candidate.substring(2); + } + if (candidate.length() < 2 || !candidate.startsWith("\"") || !candidate.endsWith("\"")) { + return Optional.empty(); + } + String value = candidate.substring(1, candidate.length() - 1); + try { + return Optional.of(new EntityTag(value, weak)); + } catch (IllegalArgumentException malformed) { + return Optional.empty(); + } + } + + /** + * Parses a comma-separated {@code If-Match} or {@code If-None-Match} list. + * + * @param headerValue the raw header, or null + * @return every tag that parsed; a malformed entry is dropped rather than failing the list + */ + public static List parseList(String headerValue) { + if (headerValue == null || headerValue.isBlank()) { + return List.of(); + } + List tags = new ArrayList<>(); + for (String entry : headerValue.split(",", -1)) { + parseSingle(entry).ifPresent(tags::add); + } + return List.copyOf(tags); + } + + /** Whether a header value is the wildcard. */ + public static boolean isWildcard(String headerValue) { + return headerValue != null && WILDCARD.equals(headerValue.trim()); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/conditional/HttpPrecondition.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/conditional/HttpPrecondition.java new file mode 100644 index 00000000..519b2079 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/conditional/HttpPrecondition.java @@ -0,0 +1,42 @@ +package dev.caskeleton.adapter.inbound.web.conditional; + +import java.util.List; +import java.util.Objects; + +/** + * The conditional headers a mutation arrived with. + * + *

Held as a value so the evaluator has one argument rather than four, and so "no precondition" + * is a state somebody constructed rather than four nulls that might mean anything. + * + * @param ifMatch the {@code If-Match} tags, empty when the header was absent + * @param ifMatchWildcard whether {@code If-Match: *} was sent + * @param ifNoneMatchWildcard whether {@code If-None-Match: *} was sent + */ +public record HttpPrecondition( + List ifMatch, boolean ifMatchWildcard, boolean ifNoneMatchWildcard) { + + public HttpPrecondition { + Objects.requireNonNull(ifMatch, "ifMatch"); + ifMatch = List.copyOf(ifMatch); + } + + /** No conditional header was sent. */ + public static HttpPrecondition none() { + return new HttpPrecondition(List.of(), false, false); + } + + /** Reads the two headers off a request. */ + public static HttpPrecondition of(String ifMatchHeader, String ifNoneMatchHeader) { + boolean ifMatchWildcard = EntityTagCodec.isWildcard(ifMatchHeader); + return new HttpPrecondition( + ifMatchWildcard ? List.of() : EntityTagCodec.parseList(ifMatchHeader), + ifMatchWildcard, + EntityTagCodec.isWildcard(ifNoneMatchHeader)); + } + + /** Whether any precondition was supplied. */ + public boolean present() { + return !ifMatch.isEmpty() || ifMatchWildcard || ifNoneMatchWildcard; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/conditional/MutationPreconditionEvaluator.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/conditional/MutationPreconditionEvaluator.java new file mode 100644 index 00000000..dfd6b5da --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/conditional/MutationPreconditionEvaluator.java @@ -0,0 +1,73 @@ +package dev.caskeleton.adapter.inbound.web.conditional; + +import dev.caskeleton.adapter.inbound.web.operation.PreconditionPolicy; +import java.util.Objects; + +/** + * Decides whether a mutation's preconditions hold. + * + *

Strong comparison, and this is where the strong/weak distinction earns its keep. A weak + * validator means "semantically equivalent", so two writers whose representations differ only + * cosmetically would both pass {@code If-Match} and the second would overwrite the first. RFC 9110 + * requires strong comparison here and so does correctness. + * + *

The 412/409 split is the design's and is easy to collapse. A 412 means a precondition the + * client supplied no longer holds — re-read and retry. A 409 means the business state conflicts and + * no conditional header was involved — retrying the same request will not help. Answering both with + * 409 loses the "re-read and retry" instruction; answering both with 412 tells a client to retry + * something that cannot succeed. + */ +public final class MutationPreconditionEvaluator { + + /** + * Evaluates a mutation's preconditions. + * + * @param policy whether this operation requires, accepts or ignores a precondition + * @param precondition what the request supplied + * @param currentTag the resource's current validator, or null when the resource does not exist + */ + public PreconditionDecision evaluate( + PreconditionPolicy policy, HttpPrecondition precondition, EntityTag currentTag) { + Objects.requireNonNull(policy, "policy"); + Objects.requireNonNull(precondition, "precondition"); + + if (precondition.ifNoneMatchWildcard()) { + // Create-only: the caller is saying "only if it does not exist yet". + return currentTag == null + ? PreconditionDecision.PROCEED + : PreconditionDecision.PRECONDITION_FAILED; + } + + if (precondition.ifMatchWildcard()) { + // Update-only: the caller is saying "only if it exists", whatever its version. + return currentTag == null + ? PreconditionDecision.PRECONDITION_FAILED + : PreconditionDecision.PROCEED; + } + + if (!precondition.ifMatch().isEmpty()) { + if (currentTag == null) { + return PreconditionDecision.PRECONDITION_FAILED; + } + boolean matched = precondition.ifMatch().stream().anyMatch(currentTag::matchesStrongly); + return matched ? PreconditionDecision.PROCEED : PreconditionDecision.PRECONDITION_FAILED; + } + + return policy == PreconditionPolicy.REQUIRED + ? PreconditionDecision.PRECONDITION_REQUIRED + : PreconditionDecision.PROCEED; + } + + /** + * Evaluates and throws rather than returning, for a call site that only handles the happy path. + * + * @throws WebPreconditionFailedException when the mutation must not run + */ + public void requireSatisfied( + PreconditionPolicy policy, HttpPrecondition precondition, EntityTag currentTag) { + PreconditionDecision decision = evaluate(policy, precondition, currentTag); + if (decision != PreconditionDecision.PROCEED) { + throw new WebPreconditionFailedException(decision); + } + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/conditional/PreconditionDecision.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/conditional/PreconditionDecision.java new file mode 100644 index 00000000..7fdf339f --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/conditional/PreconditionDecision.java @@ -0,0 +1,21 @@ +package dev.caskeleton.adapter.inbound.web.conditional; + +/** + * What a conditional mutation should do. + * + *

{@link #PRECONDITION_REQUIRED} is separate from {@link #PRECONDITION_FAILED} because they tell + * a client different things. Failed means "somebody else changed it, re-read and retry"; required + * means "you did not send a precondition and this operation will not guess" — a 428, and a client + * that retries the same request unchanged will get the same answer forever. + */ +public enum PreconditionDecision { + + /** Every precondition held: the mutation may run. */ + PROCEED, + + /** A precondition was supplied and no longer holds: 412. */ + PRECONDITION_FAILED, + + /** The operation requires a precondition and none was supplied: 428. */ + PRECONDITION_REQUIRED +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/conditional/WebPreconditionFailedException.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/conditional/WebPreconditionFailedException.java new file mode 100644 index 00000000..4b41fd8f --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/conditional/WebPreconditionFailedException.java @@ -0,0 +1,37 @@ +package dev.caskeleton.adapter.inbound.web.conditional; + +import java.util.Objects; + +/** + * A conditional mutation must not run. + * + *

Carries the decision rather than a status, so the transport maps it and the evaluator does not + * have to know about HTTP numbers. The two outcomes are genuinely different answers — 412 says + * re-read and retry, 428 says supply a precondition — and collapsing them into one exception would + * put the choice back at the call site. + * + *

Named {@code Web...} because this leaf already has a {@code PreconditionFailedException} from + * the pre-existing conditional package; two types with one name in one leaf is an import a reader + * has to check. + */ +public final class WebPreconditionFailedException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + private final PreconditionDecision decision; + + /** + * Creates the failure. + * + * @param decision which precondition outcome occurred + */ + public WebPreconditionFailedException(PreconditionDecision decision) { + super("the request's precondition was not satisfied: " + decision); + this.decision = Objects.requireNonNull(decision, "decision"); + } + + /** Which outcome occurred, so the transport can choose 412 or 428. */ + public PreconditionDecision decision() { + return decision; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/contract/WebEnumValue.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/contract/WebEnumValue.java new file mode 100644 index 00000000..87d4837e --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/contract/WebEnumValue.java @@ -0,0 +1,32 @@ +package dev.caskeleton.adapter.inbound.web.contract; + +import java.util.Objects; +import java.util.regex.Pattern; + +/** + * The wire spelling of one enum constant, stated rather than derived. + * + *

The design's rule is that a Java enum name is never automatically a wire value, and this type + * is what makes that rule enforceable. Deriving the wire value from the constant name means + * renaming a constant — a refactor an IDE performs without comment — silently breaks every client, + * and adding a constant silently extends a public contract. Both become visible when the mapping is + * a value somebody had to write. + * + * @param javaName the Java constant name + * @param wireValue the value clients send and receive + */ +public record WebEnumValue(String javaName, String wireValue) { + + private static final Pattern WIRE_GRAMMAR = Pattern.compile("[A-Za-z0-9][A-Za-z0-9_.-]{0,63}"); + + public WebEnumValue { + Objects.requireNonNull(javaName, "javaName"); + Objects.requireNonNull(wireValue, "wireValue"); + if (javaName.isBlank()) { + throw new IllegalArgumentException("enum java name is required"); + } + if (!WIRE_GRAMMAR.matcher(wireValue).matches()) { + throw new IllegalArgumentException("invalid enum wire value: " + wireValue); + } + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/contract/WebWireType.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/contract/WebWireType.java new file mode 100644 index 00000000..ff70b7a9 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/contract/WebWireType.java @@ -0,0 +1,44 @@ +package dev.caskeleton.adapter.inbound.web.contract; + +/** + * The Java types whose JSON representation the platform fixes rather than inherits. + * + *

Every entry here is a type whose default Jackson rendering is defensible and wrong for a + * public API. {@code Instant} serialises as an epoch decimal, {@code Duration} as a number of + * seconds, {@code long} as a JSON number that a JavaScript client silently rounds, and an enum as + * whatever the constant happens to be called. Each of those is a contract a client depends on, so + * each is declared once and asserted, instead of being whatever the mapper configuration was on the + * day the endpoint shipped. + */ +public enum WebWireType { + + /** A point in time. */ + INSTANT, + + /** A point in time with the originating offset preserved. */ + OFFSET_DATE_TIME, + + /** A calendar date with no time or zone. */ + LOCAL_DATE, + + /** An elapsed amount of time. */ + DURATION, + + /** An opaque identifier. */ + UUID, + + /** An exact decimal. */ + BIG_DECIMAL, + + /** A 64-bit integer, which does not survive a JSON number in every client. */ + LONG, + + /** A closed set of named values. */ + ENUM, + + /** An absolute or relative reference. */ + URI, + + /** A language tag. */ + LOCALE +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/contract/WebWireTypeManifest.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/contract/WebWireTypeManifest.java new file mode 100644 index 00000000..3458946b --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/contract/WebWireTypeManifest.java @@ -0,0 +1,81 @@ +package dev.caskeleton.adapter.inbound.web.contract; + +import java.util.EnumMap; +import java.util.Map; +import java.util.Objects; + +/** + * The one declaration of how each fixed type appears on the wire. + * + *

A manifest rather than annotations spread across DTOs, because the property that matters is a + * cross-cutting one: every {@code Instant} in every response must look the same, and a rule that + * lives on each field can only be checked by reading every field. One manifest can be asserted in a + * single test and rendered into the OpenAPI document. + * + *

{@link #require} throws rather than returning empty. A type with no rule is a type whose + * representation is whatever the mapper defaults to, which is the situation this class exists to + * end. + */ +public final class WebWireTypeManifest { + + private final Map rules; + + /** + * Creates a manifest. + * + * @param rules the declared rule for each type + * @throws IllegalArgumentException when a type has no rule, so the manifest cannot be partial + */ + public WebWireTypeManifest(Map rules) { + Objects.requireNonNull(rules, "rules"); + EnumMap copy = new EnumMap<>(WebWireType.class); + copy.putAll(rules); + for (WebWireType type : WebWireType.values()) { + if (!copy.containsKey(type)) { + throw new IllegalArgumentException( + "wire type manifest is missing a rule for " + + type + + "; a partial manifest leaves that type on the mapper's default representation"); + } + } + this.rules = Map.copyOf(copy); + } + + /** + * The platform's declared representations. + * + *

{@code LONG} is a string and that is the entry worth explaining. A 64-bit integer beyond + * 2^53 does not survive a JSON number in a JavaScript client — it is silently rounded, and the + * identifier a client echoes back is not the one it was sent. Sending it as a string costs two + * characters and removes a class of bug that only appears once identifiers grow large enough. + */ + public static WebWireTypeManifest standard() { + EnumMap rules = new EnumMap<>(WebWireType.class); + rules.put(WebWireType.INSTANT, new WireTypeRule("RFC3339_UTC", false)); + rules.put(WebWireType.OFFSET_DATE_TIME, new WireTypeRule("RFC3339_OFFSET", false)); + rules.put(WebWireType.LOCAL_DATE, new WireTypeRule("ISO8601_DATE", false)); + rules.put(WebWireType.DURATION, new WireTypeRule("ISO8601_DURATION", false)); + rules.put(WebWireType.UUID, new WireTypeRule("RFC9562_STRING", false)); + rules.put(WebWireType.BIG_DECIMAL, new WireTypeRule("DECIMAL_STRING", false)); + rules.put(WebWireType.LONG, new WireTypeRule("INTEGER_STRING", false)); + rules.put(WebWireType.ENUM, new WireTypeRule("DECLARED_WIRE_VALUE", false)); + rules.put(WebWireType.URI, new WireTypeRule("RFC3986_STRING", false)); + rules.put(WebWireType.LOCALE, new WireTypeRule("BCP47_TAG", false)); + return new WebWireTypeManifest(rules); + } + + /** + * The rule for a type. + * + * @throws NullPointerException when the type has no declared rule + */ + public WireTypeRule require(WebWireType type) { + Objects.requireNonNull(type, "type"); + return Objects.requireNonNull(rules.get(type), () -> "missing wire type rule for " + type); + } + + /** Every declared rule. */ + public Map rules() { + return rules; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/contract/WireTypeRule.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/contract/WireTypeRule.java new file mode 100644 index 00000000..79e01fc9 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/contract/WireTypeRule.java @@ -0,0 +1,24 @@ +package dev.caskeleton.adapter.inbound.web.contract; + +import java.util.Objects; + +/** + * How one Java type appears on the wire, and whether it may be absent. + * + *

Nullability is part of the rule rather than a property of each field because the two questions + * are decided together: a client parsing an RFC 3339 string needs to know whether it must also + * handle {@code null}, and answering that per field is how one endpoint ends up nullable and its + * neighbour does not for no reason anybody recorded. + * + * @param wireFormat the declared representation, for example {@code RFC3339_UTC} + * @param nullable whether the value may be JSON {@code null} + */ +public record WireTypeRule(String wireFormat, boolean nullable) { + + public WireTypeRule { + Objects.requireNonNull(wireFormat, "wireFormat"); + if (wireFormat.isBlank()) { + throw new IllegalArgumentException("wire format is required"); + } + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/core/ActorContext.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/core/ActorContext.java new file mode 100644 index 00000000..742d7564 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/core/ActorContext.java @@ -0,0 +1,52 @@ +package dev.caskeleton.adapter.inbound.web.core; + +import java.util.Objects; +import java.util.Set; + +/** + * Who is making the request, as established by the security layer. + * + *

The design's constraint is that an actor is built from a verified security context and never + * from a raw request parameter. That rule lives here rather than in a comment: the only way to + * construct an authenticated actor is {@link #authenticated}, which demands a subject, and the only + * other constructor is {@link #anonymous()}, which cannot carry one. A header value therefore has + * no path into this type that does not go through authentication first. + * + * @param subject the authenticated subject identifier, empty only for {@link #anonymous()} + * @param authenticated whether the security layer verified this actor + * @param authorities the granted authorities, already normalised by the security layer + */ +public record ActorContext(String subject, boolean authenticated, Set authorities) { + + public ActorContext { + Objects.requireNonNull(subject, "actor subject"); + Objects.requireNonNull(authorities, "actor authorities"); + if (authenticated && subject.isBlank()) { + throw new IllegalArgumentException("an authenticated actor must carry a subject"); + } + if (!authenticated && !subject.isBlank()) { + throw new IllegalArgumentException( + "an anonymous actor must not carry a subject; that pairing is how an unverified " + + "identifier reaches an audit record looking verified"); + } + authorities = Set.copyOf(authorities); + } + + /** The actor for a request the security layer did not authenticate. */ + public static ActorContext anonymous() { + return new ActorContext("", false, Set.of()); + } + + /** + * An actor the security layer verified. + * + * @param subject the verified subject identifier + * @param authorities the granted authorities + */ + public static ActorContext authenticated(String subject, Set authorities) { + if (subject == null || subject.isBlank()) { + throw new IllegalArgumentException("an authenticated actor must carry a subject"); + } + return new ActorContext(subject, true, authorities); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/core/ApiMajorVersion.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/core/ApiMajorVersion.java new file mode 100644 index 00000000..43f77605 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/core/ApiMajorVersion.java @@ -0,0 +1,32 @@ +package dev.caskeleton.adapter.inbound.web.core; + +/** + * The major version of the public API a request addressed. + * + *

Major only. A minor or patch number in the path would make every additive change a new URL, + * which is the opposite of what versioning is for: additive changes are supposed to be invisible to + * a client that does not use them. + * + *

Zero is rejected along with negatives. A {@code /api/v0} that means "unversioned" is a version + * whose contract nobody wrote down, and the platform would have no way to sunset it. + * + * @param value the major version, at least 1 + */ +public record ApiMajorVersion(int value) { + + public ApiMajorVersion { + if (value < 1) { + throw new IllegalArgumentException("api major version must be at least 1, was: " + value); + } + } + + /** The path segment this version occupies, for example {@code v1}. */ + public String pathSegment() { + return "v" + value; + } + + @Override + public String toString() { + return pathSegment(); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/core/ExternalRequestContext.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/core/ExternalRequestContext.java new file mode 100644 index 00000000..81061fc7 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/core/ExternalRequestContext.java @@ -0,0 +1,65 @@ +package dev.caskeleton.adapter.inbound.web.core; + +import java.util.Locale; +import java.util.Objects; + +/** + * How the request looked from outside the trust boundary, normalised. + * + *

The platform builds absolute URLs — {@code Location} headers, pagination links, sunset links — + * and it must build them the way a client sees the deployment, not the way the application server + * sees itself. Behind a proxy those differ: the server believes it is {@code http://10.0.0.4:8080} + * while the client asked {@code https://api.example.com/gateway}. + * + *

Only scheme, host, port and prefix are kept, and each is normalised on the way in. Everything + * else a forwarded header could carry — the original path, the query, a client-chosen prefix — is + * discarded, because this record is consumed to build URLs and anything retained here would become + * a way for a caller to choose where a {@code Location} header points. + * + * @param scheme the external scheme, lower case + * @param host the external host, lower case + * @param port the external port + * @param prefix the external path prefix, empty or beginning with a slash and never ending in one + */ +public record ExternalRequestContext(String scheme, String host, int port, String prefix) { + + public ExternalRequestContext { + Objects.requireNonNull(scheme, "scheme"); + Objects.requireNonNull(host, "host"); + Objects.requireNonNull(prefix, "prefix"); + scheme = scheme.toLowerCase(Locale.ROOT); + host = host.toLowerCase(Locale.ROOT); + if (!scheme.equals("http") && !scheme.equals("https")) { + throw new IllegalArgumentException("external scheme must be http or https, was: " + scheme); + } + if (host.isBlank()) { + throw new IllegalArgumentException("external host is required"); + } + if (port < 1 || port > 65535) { + throw new IllegalArgumentException("external port out of range: " + port); + } + if (!prefix.isEmpty()) { + if (!prefix.startsWith("/")) { + throw new IllegalArgumentException("external prefix must begin with a slash: " + prefix); + } + if (prefix.endsWith("/")) { + throw new IllegalArgumentException("external prefix must not end with a slash: " + prefix); + } + if (prefix.contains("..")) { + throw new IllegalArgumentException("external prefix must not traverse: " + prefix); + } + } + } + + /** The origin a client would type, with the default port for the scheme left implicit. */ + public String origin() { + boolean defaultPort = + (scheme.equals("https") && port == 443) || (scheme.equals("http") && port == 80); + return defaultPort ? scheme + "://" + host : scheme + "://" + host + ":" + port; + } + + /** The absolute base every generated URL is built from. */ + public String baseUrl() { + return origin() + prefix; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/core/TenantContext.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/core/TenantContext.java new file mode 100644 index 00000000..742ea591 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/core/TenantContext.java @@ -0,0 +1,50 @@ +package dev.caskeleton.adapter.inbound.web.core; + +import java.util.Objects; +import java.util.Optional; +import java.util.regex.Pattern; + +/** + * Which tenant the request is scoped to, as established by the security layer. + * + *

Same rule as {@link ActorContext} and for a sharper reason: a tenant taken from a request + * parameter is a cross-tenant read waiting to happen, because the parameter is chosen by the + * caller. {@link #resolved} is the only way to name one, and the platform calls it from the + * security bridge alone. + * + * @param tenantId the resolved tenant identifier, empty when the operation is not tenant scoped + */ +public record TenantContext(String tenantId) { + + /** Tenant identifiers are opaque, short and safe to place in a log field. */ + private static final Pattern GRAMMAR = Pattern.compile("[A-Za-z0-9][A-Za-z0-9_.-]{0,63}"); + + public TenantContext { + Objects.requireNonNull(tenantId, "tenantId"); + if (!tenantId.isEmpty() && !GRAMMAR.matcher(tenantId).matches()) { + throw new IllegalArgumentException("invalid tenant identifier: " + tenantId); + } + } + + /** The context for an operation that is not scoped to a tenant. */ + public static TenantContext none() { + return new TenantContext(""); + } + + /** + * A tenant the security layer resolved. + * + * @param tenantId the resolved tenant identifier + */ + public static TenantContext resolved(String tenantId) { + if (tenantId == null || tenantId.isBlank()) { + throw new IllegalArgumentException("a resolved tenant must carry an identifier"); + } + return new TenantContext(tenantId); + } + + /** The tenant, when the operation is scoped to one. */ + public Optional value() { + return tenantId.isEmpty() ? Optional.empty() : Optional.of(tenantId); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/core/WebOperationName.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/core/WebOperationName.java new file mode 100644 index 00000000..b19770b6 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/core/WebOperationName.java @@ -0,0 +1,36 @@ +package dev.caskeleton.adapter.inbound.web.core; + +import java.util.regex.Pattern; + +/** + * The stable name of one HTTP operation. + * + *

This is the low-cardinality identity every other part of the platform keys on: the metric tag, + * the access log field, the operation profile lookup and the OpenAPI operation id. A route template + * cannot play that role — it changes when a path is versioned or a segment is renamed — and a raw + * URI certainly cannot, because it carries identifiers and would turn one counter into a million. + * + *

The grammar is deliberately narrow. Lower case removes the "Orders" / "orders" duplicate pair + * that only shows up as two series in a dashboard; the length bound keeps a hand-written name from + * becoming a log line of its own; and the leading-letter rule keeps a name from being mistaken for + * a number by anything that later parses it. + * + * @param value the operation name, matching {@code [a-z][a-z0-9.-]{2,127}} + */ +public record WebOperationName(String value) { + + /** The only accepted shape. Anchored, so a name cannot smuggle a suffix past the check. */ + private static final Pattern GRAMMAR = Pattern.compile("[a-z][a-z0-9.-]{2,127}"); + + public WebOperationName { + if (value == null || !GRAMMAR.matcher(value).matches()) { + throw new IllegalArgumentException( + "web operation name must match [a-z][a-z0-9.-]{2,127}, was: " + value); + } + } + + @Override + public String toString() { + return value; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/core/WebRequestContext.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/core/WebRequestContext.java new file mode 100644 index 00000000..e80feb5e --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/core/WebRequestContext.java @@ -0,0 +1,78 @@ +package dev.caskeleton.adapter.inbound.web.core; + +import java.time.Duration; +import java.time.Instant; +import java.util.Locale; +import java.util.Objects; + +/** + * Everything the platform knows about one request, fixed at admission. + * + *

Immutable on purpose. This value is read by the filter chain, the controller, the idempotency + * gate, the evidence recorder and the access log, and a mutable context read by that many stages is + * a context whose value at the point of a failure nobody can reconstruct. Anything that needs to + * change produces a new context rather than editing this one. + * + *

The deadline is absolute rather than a duration. A budget expressed as "three seconds" + * restarts at every hop that reads it, so a request with a three second budget can spend nine; an + * instant cannot be accidentally renewed. + * + * @param requestId this exchange's identity + * @param traceId the distributed trace this exchange belongs to + * @param operationName the low-cardinality operation identity + * @param apiVersion the major API version addressed + * @param actor who is calling, as the security layer established it + * @param tenant which tenant the call is scoped to + * @param locale the negotiated locale + * @param receivedAt when the platform admitted the request + * @param deadline when the request's budget expires + * @param externalRequest how the request looked from outside the trust boundary + */ +public record WebRequestContext( + WebRequestId requestId, + WebTraceId traceId, + WebOperationName operationName, + ApiMajorVersion apiVersion, + ActorContext actor, + TenantContext tenant, + Locale locale, + Instant receivedAt, + Instant deadline, + ExternalRequestContext externalRequest) { + + public WebRequestContext { + Objects.requireNonNull(requestId, "requestId"); + Objects.requireNonNull(traceId, "traceId"); + Objects.requireNonNull(operationName, "operationName"); + Objects.requireNonNull(apiVersion, "apiVersion"); + Objects.requireNonNull(actor, "actor"); + Objects.requireNonNull(tenant, "tenant"); + Objects.requireNonNull(locale, "locale"); + Objects.requireNonNull(receivedAt, "receivedAt"); + Objects.requireNonNull(deadline, "deadline"); + Objects.requireNonNull(externalRequest, "externalRequest"); + if (deadline.isBefore(receivedAt)) { + throw new IllegalArgumentException( + "deadline precedes receive time; a request admitted already out of budget would be " + + "cancelled before any stage could report why"); + } + } + + /** How much of the budget is left at an instant, never negative. */ + public Duration remainingBudget(Instant now) { + Objects.requireNonNull(now, "now"); + Duration remaining = Duration.between(now, deadline); + return remaining.isNegative() ? Duration.ZERO : remaining; + } + + /** Whether the budget has run out at an instant. */ + public boolean expired(Instant now) { + Objects.requireNonNull(now, "now"); + return !now.isBefore(deadline); + } + + /** The whole budget this request was admitted with. */ + public Duration budget() { + return Duration.between(receivedAt, deadline); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/core/WebRequestId.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/core/WebRequestId.java new file mode 100644 index 00000000..632c4106 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/core/WebRequestId.java @@ -0,0 +1,32 @@ +package dev.caskeleton.adapter.inbound.web.core; + +/** + * The identity of one HTTP request as this platform sees it. + * + *

Distinct from the trace id on purpose. A trace spans every hop a call touches; a request id + * names this one exchange, and it is what a client quotes in a support ticket. Collapsing them + * means a client-supplied trace header can rename an internal request, which is both a correlation + * bug and a way to poison a log search. + * + * @param value the request identifier, non-blank + */ +public record WebRequestId(String value) { + + /** The longest accepted identifier: enough for a UUID or a trace-shaped value, and no more. */ + private static final int MAX_LENGTH = 128; + + public WebRequestId { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("web request id is required"); + } + if (value.length() > MAX_LENGTH) { + throw new IllegalArgumentException( + "web request id must be at most " + MAX_LENGTH + " characters"); + } + } + + @Override + public String toString() { + return value; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/core/WebRouteId.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/core/WebRouteId.java new file mode 100644 index 00000000..be49ad42 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/core/WebRouteId.java @@ -0,0 +1,31 @@ +package dev.caskeleton.adapter.inbound.web.core; + +/** + * The identity of a registered route. + * + *

The value is a route template — {@code GET /api/v1/orders/{orderId}} — never a resolved URI. + * The thousand URIs that template serves are one route, and treating them as many is how a route + * inventory becomes unreadable and a metric tag becomes unbounded. + * + * @param value the route template, non-blank + */ +public record WebRouteId(String value) { + + /** The longest accepted template; a route that needs more than this is a routing mistake. */ + private static final int MAX_LENGTH = 256; + + public WebRouteId { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("web route id is required"); + } + if (value.length() > MAX_LENGTH) { + throw new IllegalArgumentException( + "web route id must be at most " + MAX_LENGTH + " characters"); + } + } + + @Override + public String toString() { + return value; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/core/WebTraceId.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/core/WebTraceId.java new file mode 100644 index 00000000..1ba81b37 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/core/WebTraceId.java @@ -0,0 +1,31 @@ +package dev.caskeleton.adapter.inbound.web.core; + +/** + * The distributed trace this request belongs to. + * + *

Held as a value rather than a raw header string so the one place that decides whether an + * inbound {@code traceparent} may be believed is a constructor rather than every call site that + * reads a header. + * + * @param value the trace identifier, non-blank + */ +public record WebTraceId(String value) { + + /** The longest accepted identifier; a W3C trace id is 32 hex characters. */ + private static final int MAX_LENGTH = 128; + + public WebTraceId { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("web trace id is required"); + } + if (value.length() > MAX_LENGTH) { + throw new IllegalArgumentException( + "web trace id must be at most " + MAX_LENGTH + " characters"); + } + } + + @Override + public String toString() { + return value; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/error/BudgetProblemMapper.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/error/BudgetProblemMapper.java new file mode 100644 index 00000000..fa09a2bb --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/error/BudgetProblemMapper.java @@ -0,0 +1,76 @@ +package dev.caskeleton.adapter.inbound.web.error; + +import dev.caskeleton.adapter.inbound.web.budget.WebBudgetExceededException; +import dev.caskeleton.adapter.inbound.web.budget.WebBudgetOutcome; +import dev.caskeleton.adapter.inbound.web.budget.WebBudgetViolation; +import java.net.URI; +import java.util.List; +import java.util.Objects; + +/** + * Turns a crossed budget into the published problem document. + * + *

Here rather than in {@code budget} because the code vocabulary is here, and {@code budget} is + * a CORE module: a bound is arithmetic and stays testable without a catalog. Here rather than in + * each stack's filter because the two filters must answer identically, and a status that disagreed + * with its own body would be caught by {@code requireStatusAgreement} only after shipping. + */ +public final class BudgetProblemMapper { + + private final WebProblemFactory problems; + + /** + * A mapper over the problem catalog. + * + * @param problems the problem document factory + */ + public BudgetProblemMapper(WebProblemFactory problems) { + this.problems = Objects.requireNonNull(problems, "problems"); + } + + /** The problem code a violation is published as. */ + public static ProblemCode codeFor(WebBudgetViolation violation) { + return switch (violation) { + case URI_TOO_LONG, HEADERS_TOO_LARGE, BODY_TOO_LARGE -> ProblemCode.REQUEST_TOO_LARGE; + // Not REQUEST_TOO_LARGE: the document is a fine size and its shape is what the profile + // refuses. A client told "too large" would compress it and try again. + case TOO_MANY_QUERY_PARAMETERS, JSON_TOO_DEEP, ARRAY_TOO_LARGE, TOO_MANY_MULTIPART_PARTS -> + ProblemCode.VALIDATION_FAILED; + case EXECUTION_TIME_EXCEEDED -> ProblemCode.DEPENDENCY_TIMEOUT; + case RESPONSE_TOO_LARGE -> ProblemCode.RESPONSE_TOO_LARGE; + }; + } + + /** + * The status a violation is answered with. + * + *

Derived from the catalog rather than restated. The catalog is where a code's status is + * decided, and any second copy of that decision is a copy that will disagree. + * + * @param violation which bound was crossed + */ + public int statusFor(WebBudgetViolation violation) { + return problems.statusFor(codeFor(violation)); + } + + /** + * The problem document for a crossed budget. + * + * @param exceeded what was crossed + * @param instance the URI of this occurrence + * @param traceId the correlation identifier + */ + public WebProblem problemFor(WebBudgetExceededException exceeded, URI instance, String traceId) { + Objects.requireNonNull(exceeded, "exceeded"); + WebBudgetViolation violation = exceeded.violation(); + WebProblem problem = + problems.create( + codeFor(violation), + WebBudgetOutcome.detailFor(violation, exceeded.allowed()), + instance, + traceId, + List.of()); + problems.requireStatusAgreement(statusFor(violation), problem); + return problem; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/error/ProblemCatalog.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/error/ProblemCatalog.java new file mode 100644 index 00000000..90a8aaa0 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/error/ProblemCatalog.java @@ -0,0 +1,183 @@ +package dev.caskeleton.adapter.inbound.web.error; + +import java.net.URI; +import java.util.EnumMap; +import java.util.Map; +import java.util.Objects; + +/** + * The one mapping from a problem code to its wire meaning and HTTP status. + * + *

Registered once and consulted everywhere, because the alternative is each handler choosing a + * status. A platform where one controller answers a conflict with 409 and another with 400 has no + * error contract at all, and a client cannot write retry logic against it. + * + *

{@code DEPENDENCY_FAILURE} is pinned to 502 and {@code DEPENDENCY_TIMEOUT} to 504 rather than + * letting either be chosen per call. The distinction matters to a caller: a 504 says the request + * may still be running behind the gateway, and a 502 says it is not. + * + *

The catalog is complete by construction: a code with no definition is refused at build time + * rather than discovered when that failure first occurs in production. + */ +public final class ProblemCatalog { + + /** The stable namespace every problem type URI is published under. */ + public static final String TYPE_PREFIX = "https://problems.caskeleton.dev/web/"; + + private final Map definitions; + + /** + * Creates a catalog. + * + * @param definitions the definition for each code + * @throws IllegalArgumentException when a code has no definition + */ + public ProblemCatalog(Map definitions) { + Objects.requireNonNull(definitions, "definitions"); + EnumMap copy = new EnumMap<>(ProblemCode.class); + copy.putAll(definitions); + for (ProblemCode code : ProblemCode.values()) { + if (!copy.containsKey(code)) { + throw new IllegalArgumentException( + "problem catalog is missing a definition for " + + code + + "; an undefined code becomes a status somebody picks at the call site"); + } + } + this.definitions = Map.copyOf(copy); + } + + /** The platform's published catalog. */ + public static ProblemCatalog standard() { + EnumMap definitions = new EnumMap<>(ProblemCode.class); + definitions.put( + ProblemCode.MALFORMED_REQUEST, + new ProblemDefinition( + URI.create(TYPE_PREFIX + "malformed-request"), "Malformed request", 400, 256)); + definitions.put( + ProblemCode.BINDING_FAILED, + new ProblemDefinition( + URI.create(TYPE_PREFIX + "binding-failed"), "Request could not be bound", 400, 256)); + definitions.put( + ProblemCode.VALIDATION_FAILED, + new ProblemDefinition( + URI.create(TYPE_PREFIX + "validation-failed"), "Request failed validation", 422, 256)); + definitions.put( + ProblemCode.AUTHENTICATION_REQUIRED, + new ProblemDefinition( + URI.create(TYPE_PREFIX + "authentication-required"), + "Authentication required", + 401, + 128)); + definitions.put( + ProblemCode.ACCESS_DENIED, + new ProblemDefinition( + URI.create(TYPE_PREFIX + "access-denied"), "Access denied", 403, 128)); + definitions.put( + ProblemCode.RESOURCE_NOT_FOUND, + new ProblemDefinition( + URI.create(TYPE_PREFIX + "resource-not-found"), "Resource not found", 404, 128)); + definitions.put( + ProblemCode.RESOURCE_CONFLICT, + new ProblemDefinition( + URI.create(TYPE_PREFIX + "resource-conflict"), "Resource conflict", 409, 256)); + definitions.put( + ProblemCode.PRECONDITION_FAILED, + new ProblemDefinition( + URI.create(TYPE_PREFIX + "precondition-failed"), "Precondition failed", 412, 256)); + definitions.put( + ProblemCode.IDEMPOTENCY_KEY_REQUIRED, + new ProblemDefinition( + URI.create(TYPE_PREFIX + "idempotency-key-required"), + "Idempotency key required", + 400, + 256)); + definitions.put( + ProblemCode.IDEMPOTENCY_KEY_REUSED, + new ProblemDefinition( + URI.create(TYPE_PREFIX + "idempotency-key-reused"), + "Idempotency key reused", + 422, + 256)); + definitions.put( + ProblemCode.IDEMPOTENCY_REQUEST_IN_PROGRESS, + new ProblemDefinition( + URI.create(TYPE_PREFIX + "idempotency-request-in-progress"), + "Request already in progress", + 409, + 256)); + definitions.put( + ProblemCode.RATE_LIMITED, + new ProblemDefinition( + URI.create(TYPE_PREFIX + "rate-limited"), "Rate limit exceeded", 429, 128)); + definitions.put( + ProblemCode.ADMISSION_REJECTED, + new ProblemDefinition( + URI.create(TYPE_PREFIX + "admission-rejected"), "Service is shedding load", 503, 128)); + definitions.put( + ProblemCode.DEPENDENCY_FAILURE, + new ProblemDefinition( + URI.create(TYPE_PREFIX + "dependency-failure"), "A dependency failed", 502, 128)); + definitions.put( + ProblemCode.DEPENDENCY_TIMEOUT, + new ProblemDefinition( + URI.create(TYPE_PREFIX + "dependency-timeout"), "A dependency timed out", 504, 128)); + definitions.put( + ProblemCode.METHOD_NOT_ALLOWED, + new ProblemDefinition( + URI.create(TYPE_PREFIX + "method-not-allowed"), "Method not allowed", 405, 128)); + definitions.put( + ProblemCode.NOT_ACCEPTABLE, + new ProblemDefinition( + URI.create(TYPE_PREFIX + "not-acceptable"), "No acceptable representation", 406, 128)); + definitions.put( + ProblemCode.RESOURCE_GONE, + new ProblemDefinition( + URI.create(TYPE_PREFIX + "resource-gone"), "Resource is gone", 410, 128)); + definitions.put( + ProblemCode.REQUEST_TOO_LARGE, + new ProblemDefinition( + URI.create(TYPE_PREFIX + "request-too-large"), + "Request exceeds a size bound", + 413, + 128)); + definitions.put( + ProblemCode.UNSUPPORTED_MEDIA_TYPE, + new ProblemDefinition( + URI.create(TYPE_PREFIX + "unsupported-media-type"), + "Unsupported media type", + 415, + 128)); + definitions.put( + ProblemCode.RESPONSE_TOO_LARGE, + new ProblemDefinition( + URI.create(TYPE_PREFIX + "response-too-large"), + "Response exceeds a size bound", + 500, + 128)); + definitions.put( + ProblemCode.INTERNAL_ERROR, + new ProblemDefinition( + URI.create(TYPE_PREFIX + "internal-error"), "Internal error", 500, 128)); + return new ProblemCatalog(definitions); + } + + /** + * The definition for a code. + * + * @throws IllegalArgumentException when the code is not defined + */ + public ProblemDefinition require(ProblemCode code) { + Objects.requireNonNull(code, "code"); + ProblemDefinition definition = definitions.get(code); + if (definition == null) { + throw new IllegalArgumentException("undefined problem code: " + code); + } + return definition; + } + + /** Every definition, for rendering the published catalog document. */ + public Map definitions() { + return definitions; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/error/ProblemCode.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/error/ProblemCode.java new file mode 100644 index 00000000..56418a5e --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/error/ProblemCode.java @@ -0,0 +1,81 @@ +package dev.caskeleton.adapter.inbound.web.error; + +/** + * The stable vocabulary of failures this API reports. + * + *

A code rather than a message is what a client can branch on. A message is written for a human, + * gets reworded, gets translated, and a client that matched on its text breaks silently. These + * names are part of the published contract and change only with the API major version. + * + *

The list is deliberately closed. A controller that needs a failure this enum cannot express is + * a controller inventing a contract, and the review that adds a constant here is the review that + * decides what status it maps to and what a client should do about it. + */ +public enum ProblemCode { + + /** The request was not valid JSON, or not parseable at the transport level at all. */ + MALFORMED_REQUEST, + + /** The document parsed but could not be bound to the request model. */ + BINDING_FAILED, + + /** The request bound but violated a declared constraint. */ + VALIDATION_FAILED, + + /** No credentials, or credentials that could not be verified. */ + AUTHENTICATION_REQUIRED, + + /** Verified credentials that are not permitted to do this. */ + ACCESS_DENIED, + + /** The addressed resource does not exist, or the caller may not know that it does. */ + RESOURCE_NOT_FOUND, + + /** The request conflicts with the resource's current state. */ + RESOURCE_CONFLICT, + + /** A conditional header was supplied and no longer holds. */ + PRECONDITION_FAILED, + + /** The operation requires an idempotency key and none was supplied. */ + IDEMPOTENCY_KEY_REQUIRED, + + /** The idempotency key was already used for a different request. */ + IDEMPOTENCY_KEY_REUSED, + + /** The same idempotency key is being processed right now. */ + IDEMPOTENCY_REQUEST_IN_PROGRESS, + + /** The caller exceeded its rate allowance. */ + RATE_LIMITED, + + /** The service refused the request to protect itself, rather than because of the caller. */ + ADMISSION_REJECTED, + + /** A dependency this operation needs failed. */ + DEPENDENCY_FAILURE, + + /** A dependency this operation needs did not answer in time. */ + DEPENDENCY_TIMEOUT, + + /** The method is not served by this resource. */ + METHOD_NOT_ALLOWED, + + /** No representation the caller accepts can be produced. */ + NOT_ACCEPTABLE, + + /** The resource existed and has been permanently removed. */ + RESOURCE_GONE, + + /** The request exceeded a declared size bound. */ + REQUEST_TOO_LARGE, + + /** The request body's media type is not one this operation reads. */ + UNSUPPORTED_MEDIA_TYPE, + + /** The response would exceed a declared size bound. */ + RESPONSE_TOO_LARGE, + + /** Something failed that the platform cannot describe more precisely without leaking. */ + INTERNAL_ERROR +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/error/ProblemDefinition.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/error/ProblemDefinition.java new file mode 100644 index 00000000..5b23ad28 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/error/ProblemDefinition.java @@ -0,0 +1,34 @@ +package dev.caskeleton.adapter.inbound.web.error; + +import java.net.URI; +import java.util.Objects; + +/** + * What one {@link ProblemCode} means on the wire. + * + *

The status lives here, once, so that the HTTP status and the {@code status} member of the body + * cannot disagree — they are read from the same field. RFC 9457 allows both to be present and says + * nothing about what a client should do when they differ, which in practice means the client picks + * one and the operator debugs the other. + * + * @param type the stable URI identifying the problem kind + * @param title the short human-readable summary, which does not change per occurrence + * @param status the HTTP status this code is always reported with + * @param maxDetailLength how much per-occurrence detail this code may carry + */ +public record ProblemDefinition(URI type, String title, int status, int maxDetailLength) { + + public ProblemDefinition { + Objects.requireNonNull(type, "type"); + Objects.requireNonNull(title, "title"); + if (title.isBlank()) { + throw new IllegalArgumentException("problem title is required"); + } + if (status < 400 || status > 599) { + throw new IllegalArgumentException("a problem status must be 4xx or 5xx, was " + status); + } + if (maxDetailLength <= 0 || maxDetailLength > 2048) { + throw new IllegalArgumentException("problem detail bound out of range: " + maxDetailLength); + } + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/error/ProblemStatusMismatchException.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/error/ProblemStatusMismatchException.java new file mode 100644 index 00000000..3a36c4f3 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/error/ProblemStatusMismatchException.java @@ -0,0 +1,28 @@ +package dev.caskeleton.adapter.inbound.web.error; + +/** + * The response status and the body's {@code status} member disagreed. + * + *

Thrown before serialization rather than logged, because a body that says 409 on a 500 response + * is a contract violation a client cannot recover from: it will branch on one of them, and which + * one is not something this platform gets to decide. + */ +public final class ProblemStatusMismatchException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + /** + * Creates the failure. + * + * @param responseStatus the status the response is being written with + * @param bodyStatus the status the problem body declares + */ + public ProblemStatusMismatchException(int responseStatus, int bodyStatus) { + super( + "problem body declares status " + + bodyStatus + + " while the response is being written with " + + responseStatus + + "; a client will branch on one of them and cannot be told which"); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/error/SafeProblemDetailExtensions.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/error/SafeProblemDetailExtensions.java new file mode 100644 index 00000000..f9b31963 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/error/SafeProblemDetailExtensions.java @@ -0,0 +1,53 @@ +package dev.caskeleton.adapter.inbound.web.error; + +import java.util.Set; + +/** + * The only extension members a problem body may carry. + * + *

RFC 9457 allows arbitrary extensions and this platform allows two. The reason is the same one + * that closes {@link ProblemCode}: an open extension set means each handler decides what an error + * looks like, and the sanitising rules then have as many holes as there are handlers. Somebody adds + * {@code cause} "just for debugging" and the exception message ships to every caller. + * + *

Both allowed members are safe by construction rather than by review. {@code traceId} names a + * log entry, not a person; {@code errors} is a list of {@link ValidationIssue}, which has no field + * for the submitted value. + */ +public final class SafeProblemDetailExtensions { + + /** The correlation identifier an operator searches for. */ + public static final String TRACE_ID = "traceId"; + + /** The field-level validation issues. */ + public static final String ERRORS = "errors"; + + private static final Set ALLOWED = Set.of(TRACE_ID, ERRORS); + + private SafeProblemDetailExtensions() {} + + /** Whether an extension member may appear in a problem body. */ + public static boolean allowed(String member) { + return member != null && ALLOWED.contains(member); + } + + /** Every allowed extension member. */ + public static Set allowed() { + return ALLOWED; + } + + /** + * Refuses an extension the platform does not publish. + * + * @throws IllegalArgumentException when the member is not allowed + */ + public static void requireAllowed(String member) { + if (!allowed(member)) { + throw new IllegalArgumentException( + "problem extension '" + + member + + "' is not published; an open extension set is how an exception message ships to" + + " every caller"); + } + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/error/ThrottleProblemWriter.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/error/ThrottleProblemWriter.java new file mode 100644 index 00000000..c0c84b9e --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/error/ThrottleProblemWriter.java @@ -0,0 +1,78 @@ +package dev.caskeleton.adapter.inbound.web.error; + +import java.net.URI; +import java.time.Duration; +import java.util.List; +import java.util.Objects; + +/** + * Renders the two refusals that mean "not now" — a spent quota and a full service. + * + *

Both in one place because the pair is only useful if it stays distinguishable, and the way + * that is lost is one of them being written by hand somewhere. A 429 tells the caller its own rate + * is the problem; a 503 tells it the rate is fine and the service is not. Answering either with the + * other sends the caller looking in the wrong place. + */ +public final class ThrottleProblemWriter { + + private final WebProblemFactory problems; + + /** + * A writer over the problem catalog. + * + * @param problems the problem document factory + */ + public ThrottleProblemWriter(WebProblemFactory problems) { + this.problems = Objects.requireNonNull(problems, "problems"); + } + + /** + * The caller's quota is spent. + * + * @param instance the URI of this occurrence + * @param traceId the correlation identifier + */ + public WebProblem quotaExhausted(URI instance, String traceId) { + return problems.create( + ProblemCode.RATE_LIMITED, + "the quota for this caller is exhausted", + instance, + traceId, + List.of()); + } + + /** + * The service has no capacity. + * + * @param instance the URI of this occurrence + * @param traceId the correlation identifier + */ + public WebProblem capacityExhausted(URI instance, String traceId) { + // Deliberately says nothing about the caller. A 503 that reads like a rate limit is the reason + // clients back off individually during an incident instead of retrying when capacity returns. + return problems.create( + ProblemCode.ADMISSION_REJECTED, + "the service is shedding load and did not run this request", + instance, + traceId, + List.of()); + } + + /** The status a quota refusal carries. */ + public int quotaStatus() { + return problems.statusFor(ProblemCode.RATE_LIMITED); + } + + /** The status a capacity refusal carries. */ + public int capacityStatus() { + return problems.statusFor(ProblemCode.ADMISSION_REJECTED); + } + + /** The {@code Retry-After} value for a duration, in whole seconds and never zero. */ + public static String retryAfterSeconds(Duration retryAfter) { + // Rounded up, and floored at one. RFC 9110 counts in seconds, so a sub-second wait rendered + // honestly is "0" — which tells a client to retry immediately, the opposite of the intent. + long seconds = Math.max(1, (retryAfter.toMillis() + 999) / 1000); + return Long.toString(seconds); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/error/ValidationIssue.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/error/ValidationIssue.java new file mode 100644 index 00000000..1ee96422 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/error/ValidationIssue.java @@ -0,0 +1,33 @@ +package dev.caskeleton.adapter.inbound.web.error; + +import java.util.Objects; + +/** + * One field-level reason a request was refused. + * + *

The pointer is a JSON Pointer into the request document, not a Java property path. A client + * that sent {@code {"order":{"lines":[...]}}} can act on {@code /order/lines/0/quantity}; it cannot + * act on {@code createOrder.arg0.lines[0].quantity}, which also happens to disclose the signature + * of an internal method. + * + *

There is no {@code rejectedValue}. Echoing what the caller sent back into an error body is how + * a password typed into the wrong field ends up in a log aggregator. + * + * @param pointer JSON Pointer to the offending member of the request document + * @param code the stable, machine-readable reason + * @param message the human-readable reason, carrying no submitted content + */ +public record ValidationIssue(String pointer, String code, String message) { + + public ValidationIssue { + Objects.requireNonNull(pointer, "pointer"); + Objects.requireNonNull(code, "code"); + Objects.requireNonNull(message, "message"); + if (!pointer.isEmpty() && !pointer.startsWith("/")) { + throw new IllegalArgumentException("pointer must be a JSON Pointer: " + pointer); + } + if (code.isBlank()) { + throw new IllegalArgumentException("validation issue code is required"); + } + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/error/WebProblem.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/error/WebProblem.java new file mode 100644 index 00000000..b7df48bd --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/error/WebProblem.java @@ -0,0 +1,55 @@ +package dev.caskeleton.adapter.inbound.web.error; + +import java.net.URI; +import java.util.List; +import java.util.Objects; + +/** + * The RFC 9457 body this API answers a failure with. + * + *

The member set is fixed. RFC 9457 permits arbitrary extension members, and permitting them + * here would mean every controller could add one — at which point the error contract is whatever + * each handler felt like, and the sanitising rules have as many holes as there are call sites. The + * two extensions this platform does carry, {@code traceId} and {@code errors}, are declared members + * with declared rules. + * + *

{@code traceId} is present so an operator can find the request; it is safe to publish because + * it identifies a log entry rather than a person. Nothing else about the failure's internals — the + * exception, the query, the host — appears anywhere in this record, and {@link WebProblemSanitizer} + * is what keeps that true for the one free-text member. + * + * @param type the stable URI identifying the problem kind + * @param title the short summary for this kind of problem + * @param status the HTTP status, equal to the one on the response + * @param detail what went wrong this time, sanitised and bounded + * @param instance the URI of the specific occurrence + * @param code the stable machine-readable code + * @param traceId the correlation identifier an operator can search for + * @param errors the field-level issues, empty when the failure is not field-level + */ +public record WebProblem( + URI type, + String title, + int status, + String detail, + URI instance, + ProblemCode code, + String traceId, + List errors) { + + public WebProblem { + Objects.requireNonNull(type, "type"); + Objects.requireNonNull(title, "title"); + Objects.requireNonNull(detail, "detail"); + Objects.requireNonNull(code, "code"); + Objects.requireNonNull(traceId, "traceId"); + Objects.requireNonNull(errors, "errors"); + if (status < 400 || status > 599) { + throw new IllegalArgumentException("a problem status must be 4xx or 5xx, was " + status); + } + errors = List.copyOf(errors); + } + + /** The media type this body is served as. */ + public static final String MEDIA_TYPE = "application/problem+json"; +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/error/WebProblemFactory.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/error/WebProblemFactory.java new file mode 100644 index 00000000..37259f3a --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/error/WebProblemFactory.java @@ -0,0 +1,79 @@ +package dev.caskeleton.adapter.inbound.web.error; + +import java.net.URI; +import java.util.List; +import java.util.Objects; + +/** + * The only way a problem body is built. + * + *

Single entry point on purpose. Every rule this platform has about error responses — the status + * comes from the catalog, the detail is sanitised and bounded, the extension set is closed — is a + * rule that a controller constructing its own body would bypass. Making the factory the only + * constructor means the rules are structural rather than remembered. + */ +public final class WebProblemFactory { + + private final ProblemCatalog catalog; + private final WebProblemSanitizer sanitizer; + + /** + * Creates a factory. + * + * @param catalog the code-to-status mapping + * @param sanitizer the free-text reducer + */ + public WebProblemFactory(ProblemCatalog catalog, WebProblemSanitizer sanitizer) { + this.catalog = Objects.requireNonNull(catalog, "catalog"); + this.sanitizer = Objects.requireNonNull(sanitizer, "sanitizer"); + } + + /** A factory over the published catalog. */ + public static WebProblemFactory standard() { + return new WebProblemFactory(ProblemCatalog.standard(), new WebProblemSanitizer()); + } + + /** + * Builds a problem body. + * + * @param code the stable failure code + * @param detail what went wrong this time; sanitised before it is used + * @param instance the URI of this occurrence + * @param traceId the correlation identifier + * @param errors the field-level issues, empty when the failure is not field-level + */ + public WebProblem create( + ProblemCode code, String detail, URI instance, String traceId, List errors) { + Objects.requireNonNull(code, "code"); + Objects.requireNonNull(traceId, "traceId"); + ProblemDefinition definition = catalog.require(code); + return new WebProblem( + definition.type(), + definition.title(), + definition.status(), + sanitizer.sanitize(detail, definition.maxDetailLength()), + instance, + code, + traceId, + errors == null ? List.of() : errors); + } + + /** The status a code is always answered with. */ + public int statusFor(ProblemCode code) { + return catalog.require(code).status(); + } + + /** + * Refuses a response whose status does not match the body it carries. + * + *

Called on the way out, before serialization, by every transport adapter. + * + * @throws ProblemStatusMismatchException when the two disagree + */ + public void requireStatusAgreement(int responseStatus, WebProblem problem) { + Objects.requireNonNull(problem, "problem"); + if (responseStatus != problem.status()) { + throw new ProblemStatusMismatchException(responseStatus, problem.status()); + } + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/error/WebProblemSanitizer.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/error/WebProblemSanitizer.java new file mode 100644 index 00000000..2ae0b9ba --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/error/WebProblemSanitizer.java @@ -0,0 +1,85 @@ +package dev.caskeleton.adapter.inbound.web.error; + +import java.util.List; +import java.util.Locale; +import java.util.regex.Pattern; + +/** + * Reduces free text to something safe to publish in an error body. + * + *

Allowlist-shaped rather than a list of things to remove. A denylist of "stack traces, SQL, + * hostnames, tokens" has to anticipate every shape an internal detail can take, and the one it + * misses is published to every caller. What survives here is a single line of bounded, printable + * text with no fragment that looks like an internal identifier. + * + *

The specific patterns are the ones observed to leak in practice: a package-qualified class + * name, a stack frame, a URL, a file path, and anything that looks like a bearer credential. + */ +public final class WebProblemSanitizer { + + /** What is left when nothing survives sanitising. */ + public static final String REDACTED = "The request could not be completed."; + + private static final Pattern STACK_FRAME = Pattern.compile("(?m)^\\s*at\\s+\\S+.*$"); + private static final Pattern QUALIFIED_TYPE = + Pattern.compile("\\b(?:[a-z][a-z0-9_]*\\.){2,}[A-Z][A-Za-z0-9_$]*\\b"); + private static final Pattern URL = Pattern.compile("\\b[a-zA-Z][a-zA-Z0-9+.-]*://\\S+"); + private static final Pattern FILE_PATH = Pattern.compile("(?:/[A-Za-z0-9_.-]+){2,}"); + + /** + * A credential keyword and whatever follows it. + * + *

The trailing {@code [:=\\s]*\\S*} is the part that matters. An earlier version anchored on + * the keyword alone, which removed the word {@code Bearer} and published the token after it — a + * redaction that reads as if it worked. + */ + private static final Pattern CREDENTIAL = + Pattern.compile("(?i)\\b(?:bearer|basic|token|secret|password|apikey)\\b[:=]?\\s*\\S*"); + + private static final Pattern SQL = + Pattern.compile("(?i)\\b(?:select|insert|update|delete|drop|from|where)\\s+\\S+"); + private static final Pattern CONTROL = Pattern.compile("[\\p{Cntrl}]+"); + private static final Pattern WHITESPACE = Pattern.compile("\\s{2,}"); + + private static final List REMOVALS = + List.of(STACK_FRAME, QUALIFIED_TYPE, URL, CREDENTIAL, SQL, FILE_PATH); + + /** + * Sanitises and bounds one free-text detail. + * + * @param input the raw text, possibly an exception message + * @param maxLength the catalog's bound for this problem code + * @return safe, single-line, bounded text, never null + */ + public String sanitize(String input, int maxLength) { + if (maxLength <= 0) { + throw new IllegalArgumentException("detail bound must be positive"); + } + if (input == null || input.isBlank()) { + return REDACTED; + } + String working = input; + for (Pattern removal : REMOVALS) { + working = removal.matcher(working).replaceAll(" "); + } + working = CONTROL.matcher(working).replaceAll(" "); + working = WHITESPACE.matcher(working).replaceAll(" ").trim(); + if (working.isEmpty()) { + return REDACTED; + } + if (working.length() > maxLength) { + // Truncated rather than rejected: the surviving prefix is still useful to a caller, and a + // bound that throws would turn a long message into a second failure. + working = working.substring(0, maxLength).trim(); + } + return working.isEmpty() ? REDACTED : working; + } + + /** Whether text would survive sanitising unchanged, for asserting a message is already safe. */ + public boolean alreadySafe(String input, int maxLength) { + return input != null + && !input.isBlank() + && sanitize(input, maxLength) + .equals(input.trim().toLowerCase(Locale.ROOT).isEmpty() ? REDACTED : input.trim()); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/error/WebResourceNotVisibleException.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/error/WebResourceNotVisibleException.java new file mode 100644 index 00000000..d356abf4 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/error/WebResourceNotVisibleException.java @@ -0,0 +1,37 @@ +package dev.caskeleton.adapter.inbound.web.error; + +import java.util.Objects; + +/** + * The addressed resource is absent, or the caller may not know that it exists. + * + *

One exception for both, deliberately. Distinguishing them over the wire turns a guessable + * identifier into an oracle: an attacker enumerating ids learns which ones are real from the + * difference between 403 and 404, and for an operation resource that is a list of what other + * tenants are running. + * + *

Thrown rather than returned so that a controller cannot accidentally answer a bare 404. A + * status with no body is a failure a client cannot branch on, and it is what {@code + * ResponseEntity.notFound().build()} produces in one keystroke. + */ +public final class WebResourceNotVisibleException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + private final transient String resourceKind; + + /** + * A resource the caller may not see. + * + * @param resourceKind what kind of thing was addressed, for the published detail + */ + public WebResourceNotVisibleException(String resourceKind) { + super("no such " + Objects.requireNonNull(resourceKind, "resourceKind") + " is visible"); + this.resourceKind = resourceKind; + } + + /** What kind of thing was addressed. */ + public String resourceKind() { + return resourceKind; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/evidence/WebApplicationEvidence.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/evidence/WebApplicationEvidence.java new file mode 100644 index 00000000..09b83722 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/evidence/WebApplicationEvidence.java @@ -0,0 +1,44 @@ +package dev.caskeleton.adapter.inbound.web.evidence; + +/** + * What is known about the application's own work, independent of the HTTP exchange. + * + *

This is the axis a retry decision is made from. {@link #APPLICATION_UNKNOWN} is the reason the + * whole model exists: a call that entered the application and whose outcome was never observed is + * not a failure and not a success, and reporting it as either is how a duplicate charge happens. + * + *

{@link #APPLICATION_UNKNOWN} is therefore terminal on this axis. Nothing later in the request + * can turn it back into a definite answer, because the information that would settle it is on the + * other side of a connection that is already gone; only reconciliation against the durable record + * can, and that happens outside this request. + */ +public enum WebApplicationEvidence implements WebEvidenceAxis { + + /** The application was never entered, so no business state can have changed. */ + NOT_STARTED(10), + + /** The application was entered; whether it committed is not yet known. */ + APPLICATION_STARTED(20), + + /** The application ran and its transaction rolled back: definitely no business state changed. */ + APPLICATION_ROLLED_BACK(30), + + /** The application committed: business state definitely changed. */ + APPLICATION_COMMITTED(40), + + /** + * The application was entered and its outcome was never observed. Terminal, and not a failure. + */ + APPLICATION_UNKNOWN(50); + + private final int rank; + + WebApplicationEvidence(int rank) { + this.rank = rank; + } + + @Override + public int rank() { + return rank; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/evidence/WebEvidenceAxis.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/evidence/WebEvidenceAxis.java new file mode 100644 index 00000000..2b2d95ea --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/evidence/WebEvidenceAxis.java @@ -0,0 +1,15 @@ +package dev.caskeleton.adapter.inbound.web.evidence; + +/** + * One position on a monotonic evidence axis, with its order stated rather than implied. + * + *

The rank is declared per constant instead of taken from {@code ordinal()}. Ordinal ties the + * ordering rule to the order somebody happened to type the constants in, so inserting a stage in + * the middle — which is exactly what happens when a new admission step is added — silently changes + * which transitions count as backwards. A declared rank makes that edit visible. + */ +public interface WebEvidenceAxis { + + /** Where this value sits on its axis; higher is later, and gaps are allowed. */ + int rank(); +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/evidence/WebExecutionEvidence.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/evidence/WebExecutionEvidence.java new file mode 100644 index 00000000..9d50db0a --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/evidence/WebExecutionEvidence.java @@ -0,0 +1,57 @@ +package dev.caskeleton.adapter.inbound.web.evidence; + +import java.util.Objects; + +/** + * The three independent axes of what is known about one request, read together. + * + *

Three fields rather than one state machine, and that is the design decision this record exists + * to hold. A single linear status has to pick an order between "the application committed" and "the + * response was written", and whichever it picks becomes wrong for the case that matters: a + * committed write whose response never left. Keeping the axes apart means the pair {@code + * (APPLICATION_COMMITTED, CLIENT_OBSERVATION_UNKNOWN)} is directly representable, which is exactly + * the situation a retry policy has to be able to see. + * + * @param requestPhase how far admission got + * @param applicationEvidence what is known about the application's own work + * @param responseEvidence how much of the response this process wrote + */ +public record WebExecutionEvidence( + WebRequestPhase requestPhase, + WebApplicationEvidence applicationEvidence, + WebResponseEvidence responseEvidence) { + + public WebExecutionEvidence { + Objects.requireNonNull(requestPhase, "requestPhase"); + Objects.requireNonNull(applicationEvidence, "applicationEvidence"); + Objects.requireNonNull(responseEvidence, "responseEvidence"); + } + + /** + * Whether an automatic retry of a mutation would be safe. + * + *

Safe means one thing only: this platform can prove no business state changed. Both {@link + * WebApplicationEvidence#APPLICATION_STARTED} and {@link + * WebApplicationEvidence#APPLICATION_UNKNOWN} therefore say no — a call that entered the + * application and was not observed to roll back may have committed. + */ + public boolean safeToRetryMutation() { + return applicationEvidence == WebApplicationEvidence.NOT_STARTED + || applicationEvidence == WebApplicationEvidence.APPLICATION_ROLLED_BACK; + } + + /** + * Whether the exchange needs reconciliation rather than a verdict. + * + *

True when the application committed but the response cannot be shown to have reached the + * client, and true when the application's own outcome was never observed. In both cases the + * durable record is the only thing that can settle what happened. + */ + public boolean requiresReconciliation() { + if (applicationEvidence == WebApplicationEvidence.APPLICATION_UNKNOWN) { + return true; + } + return applicationEvidence == WebApplicationEvidence.APPLICATION_COMMITTED + && responseEvidence != WebResponseEvidence.RESPONSE_WRITE_COMPLETED_LOCALLY; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/evidence/WebExecutionEvidenceTracker.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/evidence/WebExecutionEvidenceTracker.java new file mode 100644 index 00000000..3a6dc3e2 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/evidence/WebExecutionEvidenceTracker.java @@ -0,0 +1,125 @@ +package dev.caskeleton.adapter.inbound.web.evidence; + +import java.util.Objects; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Accumulates the three evidence axes for one request, forwards only. + * + *

Monotonic because the alternative loses the fact that matters. If a later stage could reset an + * axis, a response-write failure arriving after a commit could overwrite {@code + * APPLICATION_COMMITTED} with something weaker, and the request would be reported as safe to retry. + * Every transition therefore takes the maximum of the current and the requested value, per axis, + * and a backwards request is silently a no-op rather than an error — the caller is a filter or an + * interceptor reporting what it saw, and making it responsible for ordering would put the ordering + * rule in the least reliable place. + * + *

The axes advance independently. Marking the application committed says nothing about the + * response, and marking the response written says nothing about the application. + * + *

Updated through a compare-and-set loop because a request can be completed by a container + * thread while an async dispatch is still reporting on another. + */ +public final class WebExecutionEvidenceTracker { + + private final AtomicReference current; + + private WebExecutionEvidenceTracker(WebExecutionEvidence initial) { + this.current = new AtomicReference<>(initial); + } + + /** A tracker for a request the container has just handed over. */ + public static WebExecutionEvidenceTracker received() { + return new WebExecutionEvidenceTracker( + new WebExecutionEvidence( + WebRequestPhase.HTTP_RECEIVED, + WebApplicationEvidence.NOT_STARTED, + WebResponseEvidence.NOT_COMMITTED)); + } + + /** What is known right now. */ + public WebExecutionEvidence snapshot() { + return current.get(); + } + + /** Advances the admission axis, never backwards. */ + public void markPhase(WebRequestPhase phase) { + Objects.requireNonNull(phase, "phase"); + advance( + existing -> + new WebExecutionEvidence( + later(existing.requestPhase(), phase), + existing.applicationEvidence(), + existing.responseEvidence())); + } + + /** Records that the application was entered. */ + public void markApplicationStarted() { + markApplication(WebApplicationEvidence.APPLICATION_STARTED); + } + + /** Records that the application's transaction rolled back. */ + public void markApplicationRolledBack() { + markApplication(WebApplicationEvidence.APPLICATION_ROLLED_BACK); + } + + /** Records that the application committed. */ + public void markApplicationCommitted() { + markApplication(WebApplicationEvidence.APPLICATION_COMMITTED); + } + + /** Records that the application's outcome was never observed. */ + public void markApplicationUnknown() { + markApplication(WebApplicationEvidence.APPLICATION_UNKNOWN); + } + + /** Records that status and headers are on the wire. */ + public void markResponseHeadersCommitted() { + markResponse(WebResponseEvidence.RESPONSE_HEADERS_COMMITTED); + } + + /** Records that some body bytes were written. */ + public void markResponsePartiallyWritten() { + markResponse(WebResponseEvidence.RESPONSE_PARTIALLY_WRITTEN); + } + + /** + * Records that this process finished writing the response. + * + *

Named for what it proves. The client may still have received nothing. + */ + public void markLocalResponseWriteCompleted() { + markResponse(WebResponseEvidence.RESPONSE_WRITE_COMPLETED_LOCALLY); + } + + /** Records that even the local write could not be confirmed. */ + public void markClientObservationUnknown() { + markResponse(WebResponseEvidence.CLIENT_OBSERVATION_UNKNOWN); + } + + private void markApplication(WebApplicationEvidence evidence) { + advance( + existing -> + new WebExecutionEvidence( + existing.requestPhase(), + later(existing.applicationEvidence(), evidence), + existing.responseEvidence())); + } + + private void markResponse(WebResponseEvidence evidence) { + advance( + existing -> + new WebExecutionEvidence( + existing.requestPhase(), + existing.applicationEvidence(), + later(existing.responseEvidence(), evidence))); + } + + private void advance(java.util.function.UnaryOperator transition) { + current.updateAndGet(transition); + } + + private static & WebEvidenceAxis> E later(E existing, E requested) { + return requested.rank() > existing.rank() ? requested : existing; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/evidence/WebRequestPhase.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/evidence/WebRequestPhase.java new file mode 100644 index 00000000..ea8eed7e --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/evidence/WebRequestPhase.java @@ -0,0 +1,44 @@ +package dev.caskeleton.adapter.inbound.web.evidence; + +/** + * How far a request travelled through the platform's own admission stages. + * + *

This axis answers "what did we do with the bytes", and nothing else. It does not say whether + * the application ran and it does not say whether a response reached anybody — those are separate + * axes, because collapsing them is what produces the answer "the request succeeded" for a call that + * committed a payment and then failed to reply. + * + *

The order of the constants is the order of the stages, and the tracker refuses to move + * backwards through it. + */ +public enum WebRequestPhase implements WebEvidenceAxis { + + /** The container handed the platform a request; nothing has been read from it yet. */ + HTTP_RECEIVED(10), + + /** URI, headers and forwarded facts were normalised into a trusted external view. */ + REQUEST_NORMALIZED(20), + + /** A route template was matched, so the operation identity is known. */ + ROUTE_SELECTED(30), + + /** The body was parsed and bound to a typed request model. */ + REQUEST_BOUND(40), + + /** Transport-level semantic validation passed. */ + REQUEST_VALIDATED(50), + + /** Every admission gate — budget, rate limit, idempotency — allowed the request through. */ + REQUEST_ADMITTED(60); + + private final int rank; + + WebRequestPhase(int rank) { + this.rank = rank; + } + + @Override + public int rank() { + return rank; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/evidence/WebResponseEvidence.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/evidence/WebResponseEvidence.java new file mode 100644 index 00000000..269ab58d --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/evidence/WebResponseEvidence.java @@ -0,0 +1,46 @@ +package dev.caskeleton.adapter.inbound.web.evidence; + +/** + * How much of the response this process managed to write. + * + *

Every constant here describes what happened on this side of the socket. That is the + * whole point of the axis: a server can complete a write, flush it and close cleanly while the + * client behind an intermediary receives nothing, so no value here may be read as "the client saw + * it". + * + *

{@link #RESPONSE_WRITE_COMPLETED_LOCALLY} is named the way it is for that reason — an earlier + * shape of this model called it {@code RESPONSE_SENT}, and a reader who saw that constant next to a + * committed application reasonably concluded the exchange was complete. {@link + * #CLIENT_OBSERVATION_UNKNOWN} is the honest terminal state when even the local write could not be + * confirmed. + */ +public enum WebResponseEvidence implements WebEvidenceAxis { + + /** Nothing has been written; the response can still be replaced entirely. */ + NOT_COMMITTED(10), + + /** Status and headers are on the wire, so the status code can no longer be changed. */ + RESPONSE_HEADERS_COMMITTED(20), + + /** Some body bytes were written; a failure from here cannot be reported as a clean error. */ + RESPONSE_PARTIALLY_WRITTEN(30), + + /** + * This process finished writing the response. It does not mean the client received it. + */ + RESPONSE_WRITE_COMPLETED_LOCALLY(40), + + /** The write could not be confirmed locally either: the exchange's fate is unknown. */ + CLIENT_OBSERVATION_UNKNOWN(50); + + private final int rank; + + WebResponseEvidence(int rank) { + this.rank = rank; + } + + @Override + public int rank() { + return rank; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/http/CanonicalPath.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/http/CanonicalPath.java new file mode 100644 index 00000000..a456725d --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/http/CanonicalPath.java @@ -0,0 +1,29 @@ +package dev.caskeleton.adapter.inbound.web.http; + +import java.util.Objects; + +/** + * A request path that has already been through {@link WebUriPolicy}. + * + *

A type rather than a validated {@code String} so the check cannot be skipped by accident. + * Every consumer that matters — route matching, authorization, cache keys, audit — takes this type, + * so a raw path has no way to reach them without passing the policy first. That is the whole + * mitigation for path-confusion attacks: it is not that the check is clever, it is that there is no + * second door. + * + * @param value the canonical path, absolute and already normalised + */ +public record CanonicalPath(String value) { + + public CanonicalPath { + Objects.requireNonNull(value, "value"); + if (!value.startsWith("/")) { + throw new IllegalArgumentException("canonical path must be absolute: " + value); + } + } + + @Override + public String toString() { + return value; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/http/ExternalOrigin.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/http/ExternalOrigin.java new file mode 100644 index 00000000..1e1f5432 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/http/ExternalOrigin.java @@ -0,0 +1,51 @@ +package dev.caskeleton.adapter.inbound.web.http; + +import java.util.Locale; +import java.util.Objects; + +/** + * The scheme, host and port a client actually addressed. + * + *

A configured value wherever one exists, because the alternative is deriving it from a request + * header. A password-reset link built from {@code Host} points wherever the caller said, and the + * caller receiving that link is the attacker who set the header. + * + * @param scheme the external scheme, lower case + * @param host the external host, lower case + * @param port the external port + */ +public record ExternalOrigin(String scheme, String host, int port) { + + public ExternalOrigin { + Objects.requireNonNull(scheme, "scheme"); + Objects.requireNonNull(host, "host"); + scheme = scheme.toLowerCase(Locale.ROOT); + host = host.toLowerCase(Locale.ROOT); + if (!scheme.equals("http") && !scheme.equals("https")) { + throw new IllegalArgumentException("external scheme must be http or https, was " + scheme); + } + if (host.isBlank()) { + throw new IllegalArgumentException("external host is required"); + } + if (port < 1 || port > 65535) { + throw new IllegalArgumentException("external port out of range: " + port); + } + } + + /** An https origin on the default port. */ + public static ExternalOrigin https(String host) { + return new ExternalOrigin("https", host, 443); + } + + /** The origin as a client would type it, with a default port left implicit. */ + public String value() { + boolean defaultPort = + (scheme.equals("https") && port == 443) || (scheme.equals("http") && port == 80); + return defaultPort ? scheme + "://" + host : scheme + "://" + host + ":" + port; + } + + @Override + public String toString() { + return value(); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/http/ExternalPrefix.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/http/ExternalPrefix.java new file mode 100644 index 00000000..7a345f29 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/http/ExternalPrefix.java @@ -0,0 +1,63 @@ +package dev.caskeleton.adapter.inbound.web.http; + +import java.util.Objects; + +/** + * The path prefix a gateway mounts this service under. + * + *

A type rather than a string because the mistake it prevents is applying it twice. A service + * behind {@code /api} that both reads {@code X-Forwarded-Prefix} and prepends its own configured + * prefix emits {@code /api/api/orders/1}, and the {@code Location} header that comes back is a 404 + * the client cannot diagnose. + * + *

Normalised at construction: no trailing slash, no traversal, always absolute or empty. + * + * @param value the prefix, empty or beginning with a slash and never ending in one + */ +public record ExternalPrefix(String value) { + + public ExternalPrefix { + Objects.requireNonNull(value, "value"); + value = value.trim(); + while (value.endsWith("/")) { + value = value.substring(0, value.length() - 1); + } + if (!value.isEmpty()) { + if (!value.startsWith("/")) { + throw new IllegalArgumentException("external prefix must be absolute: " + value); + } + if (value.contains("..") || value.contains("//")) { + throw new IllegalArgumentException("external prefix is not canonical: " + value); + } + } + } + + /** No prefix; the service is mounted at the root. */ + public static ExternalPrefix none() { + return new ExternalPrefix(""); + } + + /** Whether this prefix adds anything. */ + public boolean empty() { + return value.isEmpty(); + } + + /** + * Whether a path already begins with this prefix. + * + *

Segment-aware: {@code /apiary/x} does not start with {@code /api}, even though the string + * does. Getting that wrong is how a legitimate path is mistaken for an already-prefixed one and + * silently loses a segment. + */ + public boolean alreadyApplied(String path) { + if (empty() || path == null) { + return false; + } + return path.equals(value) || path.startsWith(value + "/"); + } + + @Override + public String toString() { + return value; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/http/ExternalUriBuilder.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/http/ExternalUriBuilder.java new file mode 100644 index 00000000..df45ae6b --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/http/ExternalUriBuilder.java @@ -0,0 +1,80 @@ +package dev.caskeleton.adapter.inbound.web.http; + +import java.util.Objects; + +/** + * Builds the absolute and relative URLs this API hands back. + * + *

Two rules, both of which exist because getting them wrong is silent. + * + *

The prefix is applied exactly once. A service behind a gateway that both receives {@code + * X-Forwarded-Prefix} and prepends its own configured prefix emits {@code /api/api/orders/1}, and + * the client follows a {@code Location} into a 404 it cannot diagnose. {@link + * ExternalPrefix#alreadyApplied} is segment-aware, so {@code /apiary} is not mistaken for an + * already-prefixed {@code /api} path. + * + *

The origin is never taken from a raw header. It comes from {@link ExternalUriPolicy}, which + * prefers what the deployment configured — a reset link built from {@code Host} points wherever the + * caller said it should. + */ +public final class ExternalUriBuilder { + + private final ExternalOrigin origin; + private final ExternalPrefix prefix; + + /** + * A builder for one request's external view. + * + * @param origin the resolved external origin + * @param prefix the resolved external prefix + */ + public ExternalUriBuilder(ExternalOrigin origin, ExternalPrefix prefix) { + this.origin = Objects.requireNonNull(origin, "origin"); + this.prefix = Objects.requireNonNull(prefix, "prefix"); + } + + /** + * The absolute URL for an application path. + * + * @param applicationPath an absolute application path, as the routes declare it + */ + public String absolute(String applicationPath) { + return origin.value() + relative(applicationPath); + } + + /** + * The path a client should use, prefixed exactly once. + * + * @param applicationPath an absolute application path + */ + public String relative(String applicationPath) { + Objects.requireNonNull(applicationPath, "applicationPath"); + if (!applicationPath.startsWith("/")) { + throw new IllegalArgumentException("application path must be absolute: " + applicationPath); + } + if (prefix.empty() || prefix.alreadyApplied(applicationPath)) { + return applicationPath; + } + return prefix.value() + applicationPath; + } + + /** + * The {@code Location} value for a created or accepted resource. + * + *

Absolute, because RFC 9110 permits a relative one and intermediaries have historically + * disagreed about what it is relative to. + */ + public String location(String applicationPath) { + return absolute(applicationPath); + } + + /** The resolved origin, for a test or a diagnostic. */ + public ExternalOrigin origin() { + return origin; + } + + /** The resolved prefix. */ + public ExternalPrefix prefix() { + return prefix; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/http/ExternalUriPolicy.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/http/ExternalUriPolicy.java new file mode 100644 index 00000000..61b0716a --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/http/ExternalUriPolicy.java @@ -0,0 +1,88 @@ +package dev.caskeleton.adapter.inbound.web.http; + +import dev.caskeleton.adapter.inbound.web.proxy.NormalizedForwardedHeaders; +import java.util.Objects; +import java.util.Optional; + +/** + * Where the external origin comes from, and who is allowed to influence it. + * + *

A configured origin wins over a forwarded one, always. That ordering is the point: a + * deployment that has written down its public address is stating a fact no request can change, + * while a forwarded header is a fact a proxy asserts and a misconfigured proxy asserts wrongly. + * Preferring the configuration means a security-sensitive URL — a reset link, a callback — is built + * from the deployment's own answer. + * + *

With no configured origin the forwarded value is used, because a service behind a gateway has + * no other way to know its public name. That is why the forwarded value must already have passed + * the trusted-peer check before it reaches here. + * + * @param configuredOrigin the deployment's own public origin, when it has one + * @param configuredPrefix the deployment's own mount prefix + * @param allowForwardedOrigin whether a trusted proxy may supply the origin at all + */ +public record ExternalUriPolicy( + Optional configuredOrigin, + ExternalPrefix configuredPrefix, + boolean allowForwardedOrigin) { + + public ExternalUriPolicy { + Objects.requireNonNull(configuredOrigin, "configuredOrigin"); + Objects.requireNonNull(configuredPrefix, "configuredPrefix"); + } + + /** A deployment that states its own origin and ignores whatever a proxy says. */ + public static ExternalUriPolicy configured(ExternalOrigin origin, ExternalPrefix prefix) { + return new ExternalUriPolicy(Optional.of(origin), prefix, false); + } + + /** A deployment behind a gateway that has to learn its public name from it. */ + public static ExternalUriPolicy fromTrustedProxy(ExternalPrefix prefix) { + return new ExternalUriPolicy(Optional.empty(), prefix, true); + } + + /** + * The origin to build URLs from. + * + * @param forwarded what a trusted proxy said, already normalised + * @param serverOrigin what this process sees on its own socket + */ + public ExternalOrigin resolveOrigin( + NormalizedForwardedHeaders forwarded, ExternalOrigin serverOrigin) { + Objects.requireNonNull(forwarded, "forwarded"); + Objects.requireNonNull(serverOrigin, "serverOrigin"); + if (configuredOrigin.isPresent()) { + return configuredOrigin.get(); + } + if (!allowForwardedOrigin) { + return serverOrigin; + } + String scheme = forwarded.scheme().orElse(serverOrigin.scheme()); + String host = forwarded.host().orElse(serverOrigin.host()); + int port = + forwarded + .port() + .orElseGet( + () -> + forwarded.scheme().isPresent() + // The proxy changed the scheme without stating a port, so the server's own + // port is meaningless here — it is the internal one. + ? ("https".equals(scheme) ? 443 : 80) + : serverOrigin.port()); + return new ExternalOrigin(scheme, host, port); + } + + /** + * The prefix to apply. + * + *

The configured prefix wins for the same reason the origin does, and a forwarded prefix is + * only used when the deployment declared none. + */ + public ExternalPrefix resolvePrefix(NormalizedForwardedHeaders forwarded) { + Objects.requireNonNull(forwarded, "forwarded"); + if (!configuredPrefix.empty()) { + return configuredPrefix; + } + return forwarded.prefix().map(ExternalPrefix::new).orElseGet(ExternalPrefix::none); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/http/WebHeaderName.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/http/WebHeaderName.java new file mode 100644 index 00000000..30340076 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/http/WebHeaderName.java @@ -0,0 +1,56 @@ +package dev.caskeleton.adapter.inbound.web.http; + +/** + * The header names the platform's own contracts are written in terms of. + * + *

Constants rather than literals because header names are compared in several places — the + * response contract, the sanitiser, the CORS expose list, the cache key — and a literal typo in one + * of them produces a rule that silently applies to nothing. A missing {@code Location} check is + * indistinguishable from a passing one. + */ +public final class WebHeaderName { + + /** Where a created or accepted resource can be read. */ + public static final String LOCATION = "Location"; + + /** The current validator for a resource representation. */ + public static final String ETAG = "ETag"; + + /** The validator a conditional read is asking about. */ + public static final String IF_NONE_MATCH = "If-None-Match"; + + /** The validator a conditional write requires to still hold. */ + public static final String IF_MATCH = "If-Match"; + + /** The caller-supplied key that makes a mutation safe to repeat. */ + public static final String IDEMPOTENCY_KEY = "Idempotency-Key"; + + /** How long a client should wait before repeating a refused request. */ + public static final String RETRY_AFTER = "Retry-After"; + + /** Which request dimensions a cached response varies by. */ + public static final String VARY = "Vary"; + + /** The cache directives a response carries. */ + public static final String CACHE_CONTROL = "Cache-Control"; + + /** The media type of the body. */ + public static final String CONTENT_TYPE = "Content-Type"; + + /** The declared length of the body. */ + public static final String CONTENT_LENGTH = "Content-Length"; + + /** The methods a resource serves. */ + public static final String ALLOW = "Allow"; + + /** When a deprecated resource stops being served. */ + public static final String SUNSET = "Sunset"; + + /** Whether a resource is deprecated. */ + public static final String DEPRECATION = "Deprecation"; + + /** Relations to other resources, including the deprecation notice. */ + public static final String LINK = "Link"; + + private WebHeaderName() {} +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/http/WebMethodPolicy.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/http/WebMethodPolicy.java new file mode 100644 index 00000000..996f869c --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/http/WebMethodPolicy.java @@ -0,0 +1,112 @@ +package dev.caskeleton.adapter.inbound.web.http; + +import dev.caskeleton.adapter.inbound.web.operation.HttpMethodSemantic; +import java.util.EnumSet; +import java.util.Locale; +import java.util.Optional; +import java.util.Set; + +/** + * The methods the Standard profile serves, as an allowlist. + * + *

An allowlist rather than a denylist of {@code TRACE} and {@code CONNECT}. A denylist has to + * anticipate every method somebody might send, and the interesting ones are the methods nobody + * anticipated: a custom verb that a framework routes to the same handler as {@code GET} while the + * proxy in front applies no rule to it at all. + * + *

{@code TRACE} is refused by name in the javadoc because the reason is worth stating: it + * reflects the request back, including headers the client did not set — which is how an httpOnly + * cookie becomes readable to script through a cross-site trace. + * + *

The semantic vocabulary is {@link HttpMethodSemantic}, which the operation module already + * owns. The design lists the enum under both modules; duplicating it would mean two definitions of + * "is this method safe" that can disagree, so this policy consumes the one that exists. + */ +public final class WebMethodPolicy { + + private static final Set STANDARD = + EnumSet.of( + HttpMethodSemantic.GET, + HttpMethodSemantic.HEAD, + HttpMethodSemantic.POST, + HttpMethodSemantic.PUT, + HttpMethodSemantic.PATCH, + HttpMethodSemantic.DELETE, + HttpMethodSemantic.OPTIONS); + + private final Set allowed; + + private WebMethodPolicy(Set allowed) { + this.allowed = Set.copyOf(allowed); + } + + /** The Standard profile: the seven methods a REST API is defined in terms of. */ + public static WebMethodPolicy standard() { + return new WebMethodPolicy(STANDARD); + } + + /** + * A narrower policy. + * + * @param allowed the subset of standard methods this deployment serves + */ + public static WebMethodPolicy allowing(Set allowed) { + if (allowed == null || allowed.isEmpty()) { + throw new IllegalArgumentException("a method policy must allow at least one method"); + } + if (!STANDARD.containsAll(allowed)) { + throw new IllegalArgumentException("only standard methods may be allowed"); + } + return new WebMethodPolicy(allowed); + } + + /** + * Resolves a method name to its semantic. + * + * @param rawMethod the method as it arrived on the wire + * @return the semantic + * @throws IllegalArgumentException when the method is not in the allowlist + */ + public HttpMethodSemantic require(String rawMethod) { + return resolve(rawMethod) + .orElseThrow( + () -> + new IllegalArgumentException( + "method is not served by this profile; a verb nobody anticipated is one the" + + " proxy in front applies no rule to")); + } + + /** Resolves a method name to its semantic, when the allowlist contains it. */ + public Optional resolve(String rawMethod) { + if (rawMethod == null || rawMethod.isBlank()) { + return Optional.empty(); + } + // Case-sensitively per RFC 9110: methods are tokens, and `get` is not `GET`. Accepting both + // would let a request past a proxy rule written for one spelling. + for (HttpMethodSemantic candidate : allowed) { + if (candidate.name().equals(rawMethod)) { + return Optional.of(candidate); + } + } + return Optional.empty(); + } + + /** Whether a method name is served. */ + public boolean allows(String rawMethod) { + return resolve(rawMethod).isPresent(); + } + + /** The allowed methods, for an {@code Allow} header. */ + public Set allowed() { + return allowed; + } + + /** The {@code Allow} header value this policy produces. */ + public String allowHeaderValue() { + return allowed.stream() + .map(method -> method.name().toUpperCase(Locale.ROOT)) + .sorted() + .reduce((left, right) -> left + ", " + right) + .orElseThrow(); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/http/WebResponseContract.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/http/WebResponseContract.java new file mode 100644 index 00000000..c1b27a15 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/http/WebResponseContract.java @@ -0,0 +1,46 @@ +package dev.caskeleton.adapter.inbound.web.http; + +import java.util.Map; +import java.util.Objects; + +/** + * Checks a response against the status rules before it can be written. + * + *

Applied at construction of {@link WebSuccessResponse} rather than by a filter on the way out. + * A filter runs after the status and headers may already have been committed, at which point the + * only thing it can do about a violation is log it — and a rule that can only be reported after the + * fact is documentation, not enforcement. + */ +public final class WebResponseContract { + + /** + * Validates one response. + * + * @param status the HTTP status + * @param headers the response headers + * @param body the response body, or null + * @throws IllegalArgumentException when the combination is not allowed + */ + public void validate(int status, Map headers, Object body) { + Objects.requireNonNull(headers, "headers"); + if (WebStatusContract.forbidsBody(status) && body != null) { + throw new IllegalArgumentException( + "status " + + status + + " forbids a response body; some intermediaries drop it and some forward it, so the" + + " client's experience would depend on which proxy it went through"); + } + if (WebStatusContract.requiresLocation(status) + && !headers.containsKey(WebHeaderName.LOCATION)) { + throw new IllegalArgumentException( + "status " + + status + + " requires a Location header; without it the client is told something exists and" + + " not where"); + } + if (status == WebStatusContract.NOT_MODIFIED && !headers.containsKey(WebHeaderName.ETAG)) { + throw new IllegalArgumentException( + "304 must repeat the ETag it matched, or the next conditional read has no validator"); + } + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/http/WebStatusContract.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/http/WebStatusContract.java new file mode 100644 index 00000000..8943022b --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/http/WebStatusContract.java @@ -0,0 +1,40 @@ +package dev.caskeleton.adapter.inbound.web.http; + +/** + * What each success status is allowed to carry. + * + *

The rules are RFC 9110's, restated as code because a comment cannot fail a build. A body on a + * {@code 204} is not merely wrong: some intermediaries drop it and some forward it, so the client's + * experience depends on which proxy it went through, which is the worst kind of bug to reproduce. + */ +public final class WebStatusContract { + + /** Created; the new resource must be locatable. */ + public static final int CREATED = 201; + + /** Accepted; the durable operation must be locatable. */ + public static final int ACCEPTED = 202; + + /** No content; nothing may be written. */ + public static final int NO_CONTENT = 204; + + /** Not modified; nothing may be written, and the validator must be repeated. */ + public static final int NOT_MODIFIED = 304; + + private WebStatusContract() {} + + /** Whether the status forbids a response body outright. */ + public static boolean forbidsBody(int status) { + return status == NO_CONTENT || status == NOT_MODIFIED || (status >= 100 && status < 200); + } + + /** Whether the status requires a {@code Location} header. */ + public static boolean requiresLocation(int status) { + return status == CREATED || status == ACCEPTED; + } + + /** Whether the status is a success. */ + public static boolean success(int status) { + return status >= 200 && status < 300; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/http/WebSuccessResponse.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/http/WebSuccessResponse.java new file mode 100644 index 00000000..6ed55470 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/http/WebSuccessResponse.java @@ -0,0 +1,76 @@ +package dev.caskeleton.adapter.inbound.web.http; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +/** + * One success response: a status, its headers and the resource itself. + * + *

The body is the resource, not a wrapper around it. The design forbids a global {@code + * ApiResponse} envelope and the reason is worth keeping next to the type: an envelope puts a + * second status inside a response that already has one, so a client has two places to look and they + * can disagree. It also makes every response a custom media type in practice, which costs the + * platform every generic HTTP tool — a cache that understands {@code 304}, a client generator that + * understands a schema, a proxy that understands {@code Content-Type}. + * + * @param the resource type + * @param status the HTTP status + * @param headers the response headers + * @param body the resource, or null when the status forbids one + */ +public record WebSuccessResponse(int status, Map headers, T body) { + + public WebSuccessResponse { + Objects.requireNonNull(headers, "headers"); + if (!WebStatusContract.success(status) && status != WebStatusContract.NOT_MODIFIED) { + throw new IllegalArgumentException("not a success status: " + status); + } + headers = Map.copyOf(new LinkedHashMap<>(headers)); + new WebResponseContract().validate(status, headers, body); + } + + /** A 200 carrying the resource. */ + public static WebSuccessResponse ok(T body) { + return new WebSuccessResponse<>(200, Map.of(), Objects.requireNonNull(body, "body")); + } + + /** A 201 whose new resource is locatable. */ + public static WebSuccessResponse created(String location, T body) { + return new WebSuccessResponse<>( + WebStatusContract.CREATED, Map.of(WebHeaderName.LOCATION, location), body); + } + + /** A 202 whose durable operation is locatable. */ + public static WebSuccessResponse accepted(String operationLocation, T body) { + return new WebSuccessResponse<>( + WebStatusContract.ACCEPTED, Map.of(WebHeaderName.LOCATION, operationLocation), body); + } + + /** A 204 with nothing in it. */ + public static WebSuccessResponse noContent() { + return new WebSuccessResponse<>(WebStatusContract.NO_CONTENT, Map.of(), null); + } + + /** A 304 repeating the validator the client already holds. */ + public static WebSuccessResponse notModified(String etag) { + return new WebSuccessResponse<>( + WebStatusContract.NOT_MODIFIED, Map.of(WebHeaderName.ETAG, etag), null); + } + + /** The {@code Location} header, when the status carries one. */ + public Optional location() { + return Optional.ofNullable(headers.get(WebHeaderName.LOCATION)); + } + + /** + * The same response with the body removed, for answering {@code HEAD}. + * + *

Headers are kept exactly. A {@code HEAD} whose headers differ from the {@code GET} it + * describes defeats the only reason a client sends one. + */ + public WebSuccessResponse asHeadResponse() { + return new WebSuccessResponse<>(status, headers, null); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/http/WebUriPolicy.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/http/WebUriPolicy.java new file mode 100644 index 00000000..b6adcfee --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/http/WebUriPolicy.java @@ -0,0 +1,91 @@ +package dev.caskeleton.adapter.inbound.web.http; + +import java.util.Locale; + +/** + * The single accepted spelling of a request path. + * + *

Every rule here exists because two components disagreeing about what a path means is how an + * authorization check gets bypassed. A gateway that allows {@code /api/v1/public} and a framework + * that treats {@code /api//v1/public} as the same route means the gateway checked a path the + * application never saw; an encoded {@code %2F} lets a caller smuggle a segment boundary past a + * prefix rule; a matrix parameter appends caller-controlled content to a segment that a matcher + * compares by prefix. + * + *

So the platform accepts exactly one spelling and refuses the rest rather than normalising + * them. Normalising would be friendlier and would put this service's interpretation in disagreement + * with the proxy in front of it, which is the same bug wearing a helpful expression. + * + *

Paths are case-sensitive, per RFC 3986. Case-folding them would make {@code /Admin} reach + * {@code /admin}, which is a route the deployment did not publish. + */ +public final class WebUriPolicy { + + private final int maxLength; + + private WebUriPolicy(int maxLength) { + this.maxLength = maxLength; + } + + /** The Standard profile. */ + public static WebUriPolicy standard() { + return new WebUriPolicy(2048); + } + + /** + * A policy with a different length bound. + * + * @param maxLength the longest accepted path + */ + public static WebUriPolicy withMaxLength(int maxLength) { + if (maxLength <= 0) { + throw new IllegalArgumentException("path length bound must be positive"); + } + return new WebUriPolicy(maxLength); + } + + /** + * Accepts a path in its one canonical form. + * + * @param rawPath the application path, already stripped of scheme, host and query + * @return the canonical path + * @throws IllegalArgumentException when the path is in any other form + */ + public CanonicalPath canonicalize(String rawPath) { + if (rawPath == null || !rawPath.startsWith("/")) { + throw new IllegalArgumentException("absolute application path required"); + } + if (rawPath.length() > maxLength) { + throw new IllegalArgumentException("path exceeds the accepted length bound"); + } + String lower = rawPath.toLowerCase(Locale.ROOT); + if (lower.contains("%2f")) { + throw new IllegalArgumentException( + "encoded slash is not canonical: it smuggles a segment boundary past a prefix rule"); + } + if (lower.contains("%5c") || rawPath.indexOf('\\') >= 0) { + throw new IllegalArgumentException("backslash is not a path separator here"); + } + if (rawPath.contains("//")) { + throw new IllegalArgumentException( + "duplicate slash is not canonical: a proxy and a matcher will disagree about the route"); + } + if (rawPath.indexOf(';') >= 0) { + throw new IllegalArgumentException("matrix parameters are not accepted"); + } + if (rawPath.contains("/../") || rawPath.endsWith("/..") || rawPath.contains("/./")) { + throw new IllegalArgumentException("dot segments are not canonical"); + } + if (rawPath.length() > 1 && rawPath.endsWith("/")) { + throw new IllegalArgumentException( + "trailing slash is not canonical: one resource must have one name"); + } + for (int index = 0; index < rawPath.length(); index++) { + char character = rawPath.charAt(index); + if (character < 0x20 || character == 0x7F) { + throw new IllegalArgumentException("control character in path"); + } + } + return new CanonicalPath(rawPath); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/idempotency/DeterministicCommandEncoder.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/idempotency/DeterministicCommandEncoder.java new file mode 100644 index 00000000..401ffe54 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/idempotency/DeterministicCommandEncoder.java @@ -0,0 +1,74 @@ +package dev.caskeleton.adapter.inbound.web.idempotency; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.node.ArrayNode; +import tools.jackson.databind.node.ObjectNode; + +/** + * Renders a request model into bytes that are the same for two requests that mean the same thing. + * + *

The design's rule is that raw JSON bytes are not the fingerprint source, and the reason is + * that they are not stable across anything: a client library that reorders object members, a proxy + * that reformats, a version bump that changes whitespace — each turns a retry into a different + * request and breaks the key silently. + * + *

So the model is serialised, re-read as a tree, canonicalised with object members sorted, and + * printed with no incidental whitespace. Array order is preserved, because an array is a sequence + * and reordering it does change what was asked for. + */ +public final class DeterministicCommandEncoder { + + private final ObjectMapper mapper; + + /** + * An encoder over a mapper. + * + * @param mapper the mapper the platform reads requests with + */ + public DeterministicCommandEncoder(ObjectMapper mapper) { + this.mapper = Objects.requireNonNull(mapper, "mapper"); + } + + /** + * The canonical form of a request model. + * + * @param command the bound request model, or null when the operation takes no body + */ + public String canonicalize(Object command) { + if (command == null) { + return ""; + } + try { + JsonNode tree = mapper.valueToTree(command); + return canonicalize(tree).toString(); + } catch (RuntimeException notSerialisable) { + throw new IllegalArgumentException( + "the request model cannot be canonicalised, so it cannot be fingerprinted", + notSerialisable); + } + } + + /** Sorts object members recursively, leaving array order alone. */ + private JsonNode canonicalize(JsonNode node) { + if (node.isObject()) { + ObjectNode sorted = mapper.createObjectNode(); + List names = new ArrayList<>(node.propertyNames()); + names.sort(String::compareTo); + for (String name : names) { + sorted.set(name, canonicalize(node.get(name))); + } + return sorted; + } + if (node.isArray()) { + ArrayNode array = mapper.createArrayNode(); + // Order preserved: an array is a sequence, and reordering it changes what was asked for. + node.forEach(element -> array.add(canonicalize(element))); + return array; + } + return node; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/idempotency/FingerprintHeaderPolicy.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/idempotency/FingerprintHeaderPolicy.java new file mode 100644 index 00000000..e97ca5b4 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/idempotency/FingerprintHeaderPolicy.java @@ -0,0 +1,102 @@ +package dev.caskeleton.adapter.inbound.web.idempotency; + +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.TreeMap; + +/** + * Which headers are part of what a request asked for. + * + *

An allowlist, and a short one. Most headers describe the exchange rather than the intent: + * {@code traceparent}, {@code X-Request-Id} and {@code User-Agent} differ between a request and its + * retry, so including them means a legitimate retry looks like a different request and the + * idempotency key stops working — silently, and only under the network conditions that make retries + * happen. + * + *

{@code Authorization} and {@code Cookie} are excluded for a different reason: the fingerprint + * is stored, and a stored digest of a credential is a credential in the database. + */ +public final class FingerprintHeaderPolicy { + + /** Headers that change what a request means. */ + private static final Set SEMANTIC_BY_DEFAULT = + Set.of("content-type", "content-language", "accept-language"); + + /** Headers that must never reach a stored digest. */ + private static final Set NEVER = + Set.of( + "authorization", + "proxy-authorization", + "cookie", + "set-cookie", + "traceparent", + "tracestate", + "x-request-id", + "user-agent", + "date", + "idempotency-key"); + + private final Set included; + + private FingerprintHeaderPolicy(Set included) { + this.included = Set.copyOf(included); + } + + /** The platform default: the three headers that change meaning. */ + public static FingerprintHeaderPolicy standard() { + return new FingerprintHeaderPolicy(SEMANTIC_BY_DEFAULT); + } + + /** + * A policy including additional headers. + * + * @param headerNames headers this deployment considers semantic + * @throws IllegalArgumentException when one of them is a credential or a per-exchange value + */ + public static FingerprintHeaderPolicy including(Set headerNames) { + Objects.requireNonNull(headerNames, "headerNames"); + for (String name : headerNames) { + if (NEVER.contains(name.toLowerCase(Locale.ROOT))) { + throw new IllegalArgumentException( + "header '" + + name + + "' must not be fingerprinted: it is either a credential, which would be stored as" + + " a digest, or per-exchange, which would make a legitimate retry look different"); + } + } + var included = new java.util.TreeSet<>(SEMANTIC_BY_DEFAULT); + headerNames.forEach(name -> included.add(name.toLowerCase(Locale.ROOT))); + return new FingerprintHeaderPolicy(included); + } + + /** + * The headers that contribute, lower-cased and sorted. + * + * @param headers the request headers + */ + public Map select(Map headers) { + Map selected = new TreeMap<>(); + if (headers == null) { + return selected; + } + headers.forEach( + (name, value) -> { + if (name == null || value == null) { + return; + } + String lower = name.toLowerCase(Locale.ROOT); + if (included.contains(lower) && !NEVER.contains(lower)) { + selected.put(lower, value.trim()); + } + }); + return selected; + } + + /** Whether a header contributes. */ + public boolean contributes(String headerName) { + String lower = headerName == null ? "" : headerName.toLowerCase(Locale.ROOT); + return included.contains(lower) && !NEVER.contains(lower); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/idempotency/IdempotencyAdmission.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/idempotency/IdempotencyAdmission.java new file mode 100644 index 00000000..6bc27398 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/idempotency/IdempotencyAdmission.java @@ -0,0 +1,85 @@ +package dev.caskeleton.adapter.inbound.web.idempotency; + +import dev.caskeleton.application.idempotency.IdempotencyRecord; +import java.util.Objects; +import java.util.Optional; + +/** + * What the transport should do with a keyed request. + * + *

Four outcomes rather than a boolean, because each leads somewhere different: run the + * operation, replay a stored response, tell the caller their request is already in flight, or tell + * them the key was used for something else. Collapsing them would force the fingerprint mismatch to + * share an answer with one of the others — and answering that with a replay hands the caller a + * receipt for a request they never made. + * + * @param outcome what the transport should do + * @param record the record already held, when there is one + */ +public record IdempotencyAdmission(Outcome outcome, Optional record) { + + public IdempotencyAdmission { + Objects.requireNonNull(outcome, "outcome"); + Objects.requireNonNull(record, "record"); + } + + /** What the transport should do. */ + public enum Outcome { + + /** No key was supplied and the operation does not require one: run it unguarded. */ + NOT_KEYED, + + /** The claim was won: run the operation. */ + PROCEED, + + /** A completed record exists for the same request: answer with the stored response. */ + REPLAY, + + /** Another attempt with this key is running: answer 409. */ + IN_PROGRESS, + + /** The key was used for a different request: answer 422 rather than replaying. */ + FINGERPRINT_MISMATCH + } + + /** The operation runs with no idempotency record. */ + public static IdempotencyAdmission notKeyed() { + return new IdempotencyAdmission(Outcome.NOT_KEYED, Optional.empty()); + } + + /** The claim was won. */ + public static IdempotencyAdmission proceed() { + return new IdempotencyAdmission(Outcome.PROCEED, Optional.empty()); + } + + /** A stored response answers this request. */ + public static IdempotencyAdmission replay(IdempotencyRecord record) { + return new IdempotencyAdmission(Outcome.REPLAY, Optional.of(record)); + } + + /** Another attempt holds the key. */ + public static IdempotencyAdmission inProgress(IdempotencyRecord record) { + return new IdempotencyAdmission(Outcome.IN_PROGRESS, Optional.of(record)); + } + + /** + * Another attempt holds the key and the record itself could not be read. + * + *

Reachable when a claim loses the race and the winner's record expires before it can be read. + * The answer is still 409: something else holds the key right now, and the caller's retry is the + * correct next move whether or not we can describe what it collided with. + */ + public static IdempotencyAdmission inProgressUnreadable() { + return new IdempotencyAdmission(Outcome.IN_PROGRESS, Optional.empty()); + } + + /** The key was used for a different request. */ + public static IdempotencyAdmission fingerprintMismatch(IdempotencyRecord record) { + return new IdempotencyAdmission(Outcome.FINGERPRINT_MISMATCH, Optional.of(record)); + } + + /** Whether the transport should run the operation. */ + public boolean shouldRun() { + return outcome == Outcome.PROCEED || outcome == Outcome.NOT_KEYED; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/idempotency/IdempotencyKey.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/idempotency/IdempotencyKey.java new file mode 100644 index 00000000..02e93a8b --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/idempotency/IdempotencyKey.java @@ -0,0 +1,33 @@ +package dev.caskeleton.adapter.inbound.web.idempotency; + +import java.util.regex.Pattern; + +/** + * The key a client supplies to make a mutation safe to repeat. + * + *

Bounded and printable because it becomes a database key and a log field. An unbounded key is a + * row somebody else pays for; a key with a newline in it is a log line the caller writes. + * + *

The value is opaque to the platform. A client that reuses one across different requests gets a + * conflict rather than a wrong answer, which is what the fingerprint check is for. + * + * @param value the client-supplied key + */ +public record IdempotencyKey(String value) { + + /** Long enough for a UUID or a ULID, short enough to index. */ + private static final Pattern GRAMMAR = Pattern.compile("[A-Za-z0-9._:-]{8,255}"); + + public IdempotencyKey { + if (value == null || !GRAMMAR.matcher(value).matches()) { + throw new IllegalArgumentException("an idempotency key must match [A-Za-z0-9._:-]{8,255}"); + } + } + + @Override + public String toString() { + // Never the value: this ends up in log lines, and the key identifies a caller's in-flight + // mutation, which is enough to replay somebody else's response to them. + return "IdempotencyKey[***]"; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/idempotency/IdempotencyResponsePlan.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/idempotency/IdempotencyResponsePlan.java new file mode 100644 index 00000000..3daf9dbd --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/idempotency/IdempotencyResponsePlan.java @@ -0,0 +1,97 @@ +package dev.caskeleton.adapter.inbound.web.idempotency; + +import dev.caskeleton.adapter.inbound.web.error.ProblemCode; +import dev.caskeleton.application.idempotency.IdempotencyRecord; +import dev.caskeleton.application.idempotency.StoredResponse; +import java.util.Objects; +import java.util.Optional; + +/** + * How an {@link IdempotencyAdmission} becomes an HTTP answer, decided once for both stacks. + * + *

The mapping is the contract clients depend on — 409 means "retry shortly", 422 means "your key + * is spent, pick a new one", and a replay must be visibly a replay. Letting the MVC filter and the + * WebFlux filter each decide would make that contract a property of which container happened to + * serve the request. + * + * @param runOperation whether the operation should be invoked + * @param status the response status when it should not + * @param problemCode the failure code when the answer is a problem document + * @param replayPayload the stored response when the answer is a replay + */ +public record IdempotencyResponsePlan( + boolean runOperation, + int status, + Optional problemCode, + Optional replayPayload) { + + /** Marks a response as served from a stored record rather than freshly computed. */ + public static final String REPLAYED_HEADER = "Idempotency-Replayed"; + + /** Tells a caller colliding with an in-flight attempt how long to wait. */ + public static final String RETRY_AFTER_HEADER = "Retry-After"; + + /** Seconds advertised in {@code Retry-After} when an attempt is already in flight. */ + public static final int IN_PROGRESS_RETRY_AFTER_SECONDS = 1; + + public IdempotencyResponsePlan { + Objects.requireNonNull(problemCode, "problemCode"); + Objects.requireNonNull(replayPayload, "replayPayload"); + if (runOperation && (problemCode.isPresent() || replayPayload.isPresent())) { + throw new IllegalArgumentException("a plan that runs the operation cannot also answer it"); + } + if (problemCode.isPresent() && replayPayload.isPresent()) { + throw new IllegalArgumentException("a plan cannot both fail and replay"); + } + } + + /** + * The plan for one admission. + * + * @param admission what the gate decided + * @param replayStatus the status the stored response was originally answered with + */ + public static IdempotencyResponsePlan of(IdempotencyAdmission admission, int replayStatus) { + Objects.requireNonNull(admission, "admission"); + return switch (admission.outcome()) { + case NOT_KEYED, PROCEED -> + new IdempotencyResponsePlan(true, 0, Optional.empty(), Optional.empty()); + case REPLAY -> + new IdempotencyResponsePlan( + false, + replayStatus, + Optional.empty(), + Optional.of( + admission + .record() + .map(IdempotencyRecord::response) + .map(StoredResponse::payload) + .orElseThrow( + () -> + new IllegalStateException( + "a REPLAY admission carries a COMPLETED record, and the record" + + " type already refuses to be COMPLETED without a" + + " response")))); + // 409, not 425 or 429: the caller is not too early and is not being throttled. Its own + // earlier attempt is still running, and the state it is asking about is genuinely in + // conflict until that attempt lands. + case IN_PROGRESS -> + new IdempotencyResponsePlan( + false, + 409, + Optional.of(ProblemCode.IDEMPOTENCY_REQUEST_IN_PROGRESS), + Optional.empty()); + // 422, not 409: the request is syntactically fine and nothing is racing it. What is wrong is + // that the key has already been spent on different content, and no retry of *this* request + // will ever succeed. 409 would invite exactly that useless retry. + case FINGERPRINT_MISMATCH -> + new IdempotencyResponsePlan( + false, 422, Optional.of(ProblemCode.IDEMPOTENCY_KEY_REUSED), Optional.empty()); + }; + } + + /** Whether the answer is a stored response. */ + public boolean replayed() { + return replayPayload.isPresent(); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/idempotency/SemanticRequestFingerprintFactory.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/idempotency/SemanticRequestFingerprintFactory.java new file mode 100644 index 00000000..bcdecedf --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/idempotency/SemanticRequestFingerprintFactory.java @@ -0,0 +1,94 @@ +package dev.caskeleton.adapter.inbound.web.idempotency; + +import dev.caskeleton.adapter.inbound.web.core.WebOperationName; +import dev.caskeleton.application.idempotency.RequestFingerprint; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.Map; +import java.util.Objects; + +/** + * Builds the digest that decides whether a repeated key is the same request. + * + *

Four inputs: the operation, the normalised path identifiers, the canonical request model and + * the selected headers. Each is there because leaving it out lets two different requests share a + * fingerprint — and a shared fingerprint means the second one is answered with the first one's + * stored response. + * + *

The path identifiers matter most and are the easiest to forget: {@code POST + * /accounts/1/transfers} and {@code POST /accounts/2/transfers} have identical bodies when the + * amount is the same, so a fingerprint over the body alone would let a caller's transfer from one + * account be answered with the receipt from another. + * + *

It produces {@code application-core}'s {@link RequestFingerprint} rather than a web-owned one. + * That module already owns the idempotency port, the record and the store contract, and a second + * fingerprint type in the transport would be a second answer to "is this the same request" — the + * canonical-ownership conflict this repository has had to unpick before. + * + *

What this class adds is the semantic construction the application port cannot make: + * {@code RequestFingerprint.ofSha256(byte[])} hashes the raw body, and raw bytes are not stable + * across a client library that reorders members or a proxy that reformats. + */ +public final class SemanticRequestFingerprintFactory { + + private static final char SEPARATOR = '\u001f'; + + private final DeterministicCommandEncoder encoder; + private final FingerprintHeaderPolicy headerPolicy; + + /** + * A factory over an encoder and a header policy. + * + * @param encoder renders the request model canonically + * @param headerPolicy decides which headers are part of the request's meaning + */ + public SemanticRequestFingerprintFactory( + DeterministicCommandEncoder encoder, FingerprintHeaderPolicy headerPolicy) { + this.encoder = Objects.requireNonNull(encoder, "encoder"); + this.headerPolicy = Objects.requireNonNull(headerPolicy, "headerPolicy"); + } + + /** + * The fingerprint of one keyed request. + * + * @param operation the operation being invoked + * @param pathVariables the identifiers the path named, keyed by variable name + * @param command the bound request model, or null + * @param headers the request headers + */ + public RequestFingerprint create( + WebOperationName operation, + Map pathVariables, + Object command, + Map headers) { + Objects.requireNonNull(operation, "operation"); + StringBuilder canonical = new StringBuilder(); + canonical.append(operation.value()); + new java.util.TreeMap<>(pathVariables == null ? Map.of() : pathVariables) + .forEach( + (name, value) -> canonical.append(SEPARATOR).append(name).append('=').append(value)); + canonical.append(SEPARATOR).append(encoder.canonicalize(command)); + headerPolicy + .select(headers) + .forEach( + (name, value) -> canonical.append(SEPARATOR).append(name).append(':').append(value)); + return new RequestFingerprint(digest(canonical.toString())); + } + + /** + * Lower-case hex, because that is the shape {@code RequestFingerprint} accepts. + * + *

Base64 would be shorter and would be rejected: the application type validates its input, so + * the encoding is part of that contract rather than this class's choice. + */ + private static String digest(String canonical) { + try { + MessageDigest sha256 = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(sha256.digest(canonical.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException impossible) { + throw new IllegalStateException("SHA-256 is required by every JVM", impossible); + } + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/idempotency/WebIdempotencyGate.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/idempotency/WebIdempotencyGate.java new file mode 100644 index 00000000..d4c5c53c --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/idempotency/WebIdempotencyGate.java @@ -0,0 +1,180 @@ +package dev.caskeleton.adapter.inbound.web.idempotency; + +import dev.caskeleton.adapter.inbound.web.core.WebOperationName; +import dev.caskeleton.adapter.inbound.web.operation.IdempotencyPolicy; +import dev.caskeleton.application.idempotency.IdempotencyRecord; +import dev.caskeleton.application.idempotency.IdempotencyScope; +import dev.caskeleton.application.idempotency.IdempotencyStatus; +import dev.caskeleton.application.idempotency.IdempotencyStorePort; +import dev.caskeleton.application.idempotency.RequestFingerprint; +import dev.caskeleton.application.idempotency.StoredResponse; +import java.time.Clock; +import java.time.Duration; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +/** + * The transport-neutral half of idempotent admission. + * + *

Neutral because the decision is identical for MVC and WebFlux: read the key, build the scope + * and the semantic fingerprint, claim, and interpret what came back. Only the reading of headers + * and the writing of the response differ between the stacks, so only those live in the stack + * adapters — two copies of this logic would be two chances for one of them to answer a fingerprint + * mismatch with a replay. + * + *

It sits on {@code application-core}'s {@link IdempotencyStorePort}, which is where this + * repository already keeps the authoritative store and its JPA adapter. The web layer contributes + * the parts that are genuinely HTTP: the header's grammar, the actor and tenant scoping, and the + * semantic fingerprint that a raw-byte digest cannot provide. + */ +public final class WebIdempotencyGate { + + private final IdempotencyStorePort store; + private final SemanticRequestFingerprintFactory fingerprints; + private final Clock clock; + private final Duration timeToLive; + + /** + * A gate over the application store. + * + * @param store the authoritative record store + * @param fingerprints builds the semantic fingerprint + * @param clock the clock expiry is measured against + * @param timeToLive how long a record lives + */ + public WebIdempotencyGate( + IdempotencyStorePort store, + SemanticRequestFingerprintFactory fingerprints, + Clock clock, + Duration timeToLive) { + this.store = Objects.requireNonNull(store, "store"); + this.fingerprints = Objects.requireNonNull(fingerprints, "fingerprints"); + this.clock = Objects.requireNonNull(clock, "clock"); + this.timeToLive = Objects.requireNonNull(timeToLive, "timeToLive"); + if (timeToLive.isZero() || timeToLive.isNegative()) { + throw new IllegalArgumentException("an idempotency record with no lifetime is not a record"); + } + } + + /** + * Decides what to do with a keyed request. + * + * @param policy whether the operation refuses, accepts or requires a key + * @param operation the operation being invoked + * @param principal the authenticated subject + * @param tenantId the tenant, or null when the operation is not tenant scoped + * @param rawKey the {@code Idempotency-Key} header, or null + * @param pathVariables the identifiers the path named + * @param command the bound request model + * @param headers the request headers + */ + public IdempotencyAdmission admit( + IdempotencyPolicy policy, + WebOperationName operation, + String principal, + String tenantId, + String rawKey, + Map pathVariables, + Object command, + Map headers) { + Objects.requireNonNull(policy, "policy"); + Objects.requireNonNull(operation, "operation"); + + if (rawKey == null || rawKey.isBlank()) { + if (policy == IdempotencyPolicy.REQUIRED) { + throw new IdempotencyKeyRequiredException(); + } + return IdempotencyAdmission.notKeyed(); + } + if (policy == IdempotencyPolicy.FORBIDDEN) { + throw new IdempotencyKeyNotAcceptedException(); + } + + // Validated before it becomes a storage key or a log field. An unbounded key is a row somebody + // else pays for; a key with a newline is a log line the caller writes. + IdempotencyKey key = new IdempotencyKey(rawKey.trim()); + IdempotencyScope scope = scope(operation, principal, tenantId, key); + RequestFingerprint fingerprint = + fingerprints.create(operation, pathVariables, command, headers); + + if (store.tryBegin(scope, fingerprint, clock.instant().plus(timeToLive))) { + return IdempotencyAdmission.proceed(); + } + Optional existing = store.find(scope, clock.instant()); + if (existing.isEmpty()) { + // The record expired between the failed claim and this read. Retrying the claim once is + // correct and terminating: the second failure means somebody else won it. + return store.tryBegin(scope, fingerprint, clock.instant().plus(timeToLive)) + ? IdempotencyAdmission.proceed() + : IdempotencyAdmission.inProgressUnreadable(); + } + IdempotencyRecord record = existing.get(); + if (!record.fingerprint().equals(fingerprint)) { + return IdempotencyAdmission.fingerprintMismatch(record); + } + return record.status() == IdempotencyStatus.COMPLETED + ? IdempotencyAdmission.replay(record) + : IdempotencyAdmission.inProgress(record); + } + + /** Stores the response so a repeat replays it. */ + public void complete( + WebOperationName operation, + String principal, + String tenantId, + String rawKey, + String payload) { + store.complete( + scope(operation, principal, tenantId, new IdempotencyKey(rawKey.trim())), + new StoredResponse(payload)); + } + + /** + * Releases the claim so the key may be retried. + * + *

Only safe when the application is known not to have committed. When that is unknown the + * claim must be left in place: discarding it turns an unobserved write into a retry, and the + * retry is the duplicate. + */ + public void discard( + WebOperationName operation, String principal, String tenantId, String rawKey) { + store.discard(scope(operation, principal, tenantId, new IdempotencyKey(rawKey.trim()))); + } + + private static IdempotencyScope scope( + WebOperationName operation, String principal, String tenantId, IdempotencyKey key) { + if (principal == null || principal.isBlank()) { + throw new IllegalArgumentException( + "an idempotency scope needs an authenticated principal; an anonymous one would let any" + + " caller replay another's stored response by guessing a key"); + } + return tenantId == null || tenantId.isBlank() + ? IdempotencyScope.of(principal, key.value(), operation.value()) + : IdempotencyScope.of(tenantId, principal, key.value(), operation.value()); + } + + /** The operation requires a key and none was supplied. */ + public static final class IdempotencyKeyRequiredException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + /** Creates the failure. */ + public IdempotencyKeyRequiredException() { + super("this operation requires an Idempotency-Key header"); + } + } + + /** The operation refuses a key and one was supplied. */ + public static final class IdempotencyKeyNotAcceptedException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + /** Creates the failure. */ + public IdempotencyKeyNotAcceptedException() { + super( + "this operation does not accept an Idempotency-Key; honouring one would tell the client" + + " a repeat is being deduplicated when it is not"); + } + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/json/BoundedJsonFactory.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/json/BoundedJsonFactory.java new file mode 100644 index 00000000..e35af8b4 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/json/BoundedJsonFactory.java @@ -0,0 +1,45 @@ +package dev.caskeleton.adapter.inbound.web.json; + +import java.util.Objects; +import tools.jackson.core.StreamReadConstraints; +import tools.jackson.core.StreamReadFeature; +import tools.jackson.core.json.JsonFactory; +import tools.jackson.core.json.JsonFactoryBuilder; + +/** + * Builds the reader whose limits are enforced before a document is materialised. + * + *

The constraints go on the {@code JsonFactory} rather than being checked after parsing, and + * that placement is the whole point. A depth limit applied to a parsed tree has already paid for + * the tree; a nesting bomb is cheap to send and expensive to hold, so the only limit that helps is + * one the streaming parser refuses to exceed. + * + *

Jackson 3 ({@code tools.jackson}), not Jackson 2. Both are on this classpath, and Spring + * Framework 7's {@code JacksonJsonHttpMessageConverter} takes a {@code tools.jackson} mapper — so a + * strict Jackson 2 mapper would be a bean the framework never consults, which is a control that + * exists and reads nothing. + */ +public final class BoundedJsonFactory { + + private BoundedJsonFactory() {} + + /** + * A factory bounded by a profile. + * + * @param profile the limits and strictness to apply + */ + public static JsonFactory create(WebJsonProfile profile) { + Objects.requireNonNull(profile, "profile"); + JsonFactoryBuilder builder = + JsonFactory.builder() + .streamReadConstraints( + StreamReadConstraints.builder() + .maxNestingDepth(profile.maxDepth()) + .maxStringLength(profile.maxStringBytes()) + .build()); + if (profile.rejectDuplicateKeys()) { + builder.enable(StreamReadFeature.STRICT_DUPLICATE_DETECTION); + } + return builder.build(); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/json/WebJsonDecodingException.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/json/WebJsonDecodingException.java new file mode 100644 index 00000000..7f3fd014 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/json/WebJsonDecodingException.java @@ -0,0 +1,36 @@ +package dev.caskeleton.adapter.inbound.web.json; + +/** + * A request body that could not be read under the strict profile. + * + *

Its own type so the error translator can tell a malformed document (400) from a well-formed + * document that violates a transport rule (422) without inspecting Jackson's exception hierarchy at + * the call site. The distinction is the design's, and it is only usable if it survives the throw. + * + *

The message never carries the offending content. A parse failure message that quotes the body + * is how a request body reaches a log that the body's own classification says it must not. + */ +public final class WebJsonDecodingException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + private final boolean malformed; + + /** + * Creates a decoding failure. + * + * @param message what went wrong, naming no content + * @param malformed true when the document is not valid JSON at all, false when it parsed but + * violated a declared rule + * @param cause the underlying reader failure + */ + public WebJsonDecodingException(String message, boolean malformed, Throwable cause) { + super(message, cause); + this.malformed = malformed; + } + + /** Whether the document was not valid JSON, which is a 400 rather than a 422. */ + public boolean malformed() { + return malformed; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/json/WebJsonProfile.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/json/WebJsonProfile.java new file mode 100644 index 00000000..61269036 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/json/WebJsonProfile.java @@ -0,0 +1,51 @@ +package dev.caskeleton.adapter.inbound.web.json; + +/** + * Which strictness the JSON reader applies, as one reviewable value. + * + *

Every flag here defaults to lenient in Jackson, and every lenient default is a way for two + * parties to disagree about what a document meant. A duplicate key means the last one wins and the + * client believes the first one did; an unknown property means a typo'd field name is silently + * dropped and the request "succeeds" without doing what was asked; a trailing token means a + * concatenated second document is ignored. + * + * @param rejectUnknownProperties whether a property the model does not declare fails the request + * @param rejectDuplicateKeys whether a repeated JSON key fails the request + * @param rejectTrailingTokens whether content after the first document fails the request + * @param caseSensitiveEnums whether an enum value must match its declared spelling exactly + * @param rejectScalarCoercion whether number-to-string and empty-string-to-null are refused + * @param maxDepth deepest accepted nesting + * @param maxArrayElements most elements accepted in one array + * @param maxStringBytes longest accepted string + */ +public record WebJsonProfile( + boolean rejectUnknownProperties, + boolean rejectDuplicateKeys, + boolean rejectTrailingTokens, + boolean caseSensitiveEnums, + boolean rejectScalarCoercion, + int maxDepth, + int maxArrayElements, + int maxStringBytes) { + + public WebJsonProfile { + if (maxDepth <= 0 || maxArrayElements <= 0 || maxStringBytes <= 0) { + throw new IllegalArgumentException("json profile limits must be positive"); + } + } + + /** Everything strict. The profile a mutation is read with. */ + public static WebJsonProfile strict() { + return new WebJsonProfile(true, true, true, true, true, 64, 100_000, 1_048_576); + } + + /** + * Strict except for unknown properties. + * + *

For reading a document this service did not author — a provider callback, a stored replay — + * where an added field is the other party evolving their contract, not a client mistake. + */ + public static WebJsonProfile tolerantOfUnknownProperties() { + return new WebJsonProfile(false, true, true, true, true, 64, 100_000, 1_048_576); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/json/WebObjectMapperFactory.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/json/WebObjectMapperFactory.java new file mode 100644 index 00000000..b1beacf0 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/json/WebObjectMapperFactory.java @@ -0,0 +1,111 @@ +package dev.caskeleton.adapter.inbound.web.json; + +import java.util.Objects; +import tools.jackson.databind.DeserializationFeature; +import tools.jackson.databind.MapperFeature; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.cfg.CoercionAction; +import tools.jackson.databind.cfg.CoercionInputShape; +import tools.jackson.databind.cfg.EnumFeature; +import tools.jackson.databind.json.JsonMapper; +import tools.jackson.databind.type.LogicalType; + +/** + * The strict reader the platform reads request bodies with. + * + *

A factory rather than a bean definition because the same strictness has to be applied by the + * MVC converter, the WebFlux codec, the idempotency replay decoder and the contract tests, and four + * independently configured mappers is four chances for one of them to be lenient. The one that is + * lenient is the one an attacker finds. + * + *

Built on Jackson 3 ({@code tools.jackson}) because that is the mapper Spring Framework 7's + * message converter actually takes. An earlier version of this class used {@code com.fasterxml}, + * which is also on this classpath — the mapper was correct, strict, unit tested, and never + * consulted by the framework for a single request. + * + *

Coercion is switched off explicitly rather than left to Jackson's defaults. Implicit + * number-to-string means {@code {"quantity": "5"}} and {@code {"quantity": 5}} are the same request + * to this service and different requests to the next one; implicit empty-string-to-null means a + * client that sends {@code ""} for a required field gets a null-pointer failure deep in the + * application instead of a validation error at the boundary. + */ +public final class WebObjectMapperFactory { + + private WebObjectMapperFactory() {} + + /** The mapper for reading a mutation request body. */ + public static ObjectMapper standard() { + return create(WebJsonProfile.strict()); + } + + /** The Jackson 3 {@code JsonMapper} Spring's message converter is configured with. */ + public static JsonMapper standardJsonMapper() { + return jsonMapper(WebJsonProfile.strict()); + } + + /** + * A mapper configured by a profile. + * + * @param profile the strictness and limits to apply + */ + public static ObjectMapper create(WebJsonProfile profile) { + return jsonMapper(profile); + } + + /** The Jackson 3 mapper the framework is configured with. */ + public static JsonMapper jsonMapper(WebJsonProfile profile) { + Objects.requireNonNull(profile, "profile"); + JsonMapper.Builder builder = + JsonMapper.builder(BoundedJsonFactory.create(profile)) + // Jackson 3 already writes dates as ISO strings by default and moved the date and + // enum switches out of SerializationFeature, so the wire manifest's RFC 3339 rule needs + // no override here — only the enum strictness does. + .enable(EnumFeature.FAIL_ON_NUMBERS_FOR_ENUMS) + .configure( + DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, + profile.rejectUnknownProperties()) + .configure( + DeserializationFeature.FAIL_ON_TRAILING_TOKENS, profile.rejectTrailingTokens()) + .configure(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS, !profile.caseSensitiveEnums()) + .configure( + MapperFeature.ACCEPT_CASE_INSENSITIVE_PROPERTIES, !profile.caseSensitiveEnums()) + .disable(DeserializationFeature.ACCEPT_FLOAT_AS_INT) + .enable(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES); + + if (profile.rejectScalarCoercion()) { + refuseImplicitCoercion(builder); + } + return builder.build(); + } + + /** + * Turns off the shape conversions Jackson performs silently. + * + *

Configured per logical type rather than globally because the defaults differ by type and a + * single global switch would leave the ones it does not cover. + */ + private static void refuseImplicitCoercion(JsonMapper.Builder builder) { + builder.withCoercionConfigDefaults( + config -> + config + .setCoercion(CoercionInputShape.EmptyString, CoercionAction.Fail) + .setCoercion(CoercionInputShape.EmptyArray, CoercionAction.Fail) + .setCoercion(CoercionInputShape.EmptyObject, CoercionAction.Fail)); + for (LogicalType type : + new LogicalType[] {LogicalType.Integer, LogicalType.Float, LogicalType.Boolean}) { + builder.withCoercionConfig( + type, + config -> + config + .setCoercion(CoercionInputShape.String, CoercionAction.Fail) + .setCoercion(CoercionInputShape.EmptyString, CoercionAction.Fail)); + } + builder.withCoercionConfig( + LogicalType.Textual, + config -> + config + .setCoercion(CoercionInputShape.Integer, CoercionAction.Fail) + .setCoercion(CoercionInputShape.Float, CoercionAction.Fail) + .setCoercion(CoercionInputShape.Boolean, CoercionAction.Fail)); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/moduleboundary/WebModuleBoundary.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/moduleboundary/WebModuleBoundary.java new file mode 100644 index 00000000..70b8727c --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/moduleboundary/WebModuleBoundary.java @@ -0,0 +1,108 @@ +package dev.caskeleton.adapter.inbound.web.moduleboundary; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +/** + * The declared module map of the web platform: identities, purity grades and allowed edges. + * + *

This type is the read side of {@link WebStableModule}. It answers the two questions every + * boundary check needs — "which module owns this package?" and "is this edge declared?" — so the + * rules never re-implement package-prefix arithmetic. + * + *

Note what it deliberately does not do: it never reads the source tree. Scanning the checkout + * is build-time work and lives in the test source set, because a running application cannot + * meaningfully react to its own source layout and a runtime scan only fails in the environments + * where the sources are absent. + */ +public final class WebModuleBoundary { + + /** The package that carries the whole platform. */ + public static final String PACKAGE_ROOT = deriveRootPackage(); + + private WebModuleBoundary() {} + + /** Every declared module identifier. */ + public static Set allModuleIds() { + return WebStableModule.moduleIds(); + } + + /** Every declared dependency edge, in deterministic order. */ + public static Map> dependencyEdges() { + return WebStableModule.dependencyEdges(); + } + + /** Every module that must not reference a framework type. */ + public static Set coreModuleIds() { + return WebStableModule.coreModuleIds(); + } + + /** The declared package of every module, keyed by identifier. */ + public static Map packagesById() { + return Map.copyOf(new LinkedHashMap<>(WebStableModule.packagesById())); + } + + /** + * The module owning a package. + * + *

Ownership is by longest declared package prefix, so {@code ...web.fileserver.controller} + * belongs to {@code fileserver.controller} rather than to a hypothetical {@code fileserver} + * module. + * + *

A module whose package is the platform root would own the root package itself and nothing + * below it. Letting it absorb descendants would make every possible package "declared", and the + * unregistered-package rule — the one that forces a new sub-package to be given an identity and + * an edge set before it can ship — would silently never fire. + * + * @param packageName a fully qualified package name + * @return the owning module identifier, or empty when the package is outside the platform or + * belongs to no declared module + */ + public static Optional moduleIdForPackage(String packageName) { + if (packageName == null || !insidePlatform(packageName)) { + return Optional.empty(); + } + String best = null; + String bestPackage = null; + for (Map.Entry candidate : packagesById().entrySet()) { + String candidatePackage = candidate.getValue(); + boolean owns = + candidatePackage.equals(PACKAGE_ROOT) + ? packageName.equals(PACKAGE_ROOT) + : matches(packageName, candidatePackage); + if (!owns) { + continue; + } + if (bestPackage == null || candidatePackage.length() > bestPackage.length()) { + best = candidate.getKey(); + bestPackage = candidatePackage; + } + } + return Optional.ofNullable(best); + } + + /** Whether a package name belongs to the platform at all. */ + public static boolean insidePlatform(String packageName) { + return packageName != null && matches(packageName, PACKAGE_ROOT); + } + + /** Whether an edge between two declared modules is allowed. */ + public static boolean edgeAllowed(String from, String to) { + if (from == null || to == null || from.equals(to)) { + return true; + } + Set allowed = dependencyEdges().get(from); + return allowed != null && allowed.contains(to); + } + + private static boolean matches(String packageName, String candidate) { + return packageName.equals(candidate) || packageName.startsWith(candidate + "."); + } + + private static String deriveRootPackage() { + String self = WebModuleBoundary.class.getPackageName(); + return self.substring(0, self.lastIndexOf('.')); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/moduleboundary/WebModulePurity.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/moduleboundary/WebModulePurity.java new file mode 100644 index 00000000..6cfcb639 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/moduleboundary/WebModulePurity.java @@ -0,0 +1,21 @@ +package dev.caskeleton.adapter.inbound.web.moduleboundary; + +/** + * Whether a web platform module may reference Servlet, Spring, Reactor or Jackson types. + * + *

The design this leaf implements states the split as a hard constraint: {@code web-core-api} + * must not depend on Servlet, Spring MVC, Spring WebFlux or Reactor. That rule is what keeps the + * HTTP semantics portable — a budget, a problem code or a cursor is a decision, and a decision that + * needs a servlet container to evaluate cannot be unit tested, cannot be reused by the WebFlux + * transport, and cannot be promoted to its own Gradle leaf later. + * + *

So purity is declared per module and checked against the real imports rather than trusted. + */ +public enum WebModulePurity { + + /** Java standard library only: no Spring, Servlet, Reactor, Jackson, Micrometer or Jakarta. */ + CORE, + + /** May bind to the framework, because it is the transport seam that has to. */ + FRAMEWORK_BOUND +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/moduleboundary/WebStableModule.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/moduleboundary/WebStableModule.java new file mode 100644 index 00000000..40e3687b --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/moduleboundary/WebStableModule.java @@ -0,0 +1,539 @@ +package dev.caskeleton.adapter.inbound.web.moduleboundary; + +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.stream.Collectors; + +/** + * The Stable web platform modules, their purity grade and their allowed internal dependencies. + * + *

The design (`docs/superpowers/specs/2026-08-13-web-inbound-http-api-execution-platform-design` + * in the web package) models this platform as 23 Gradle modules under {@code modules/web}. This + * repository's fail-closed module registry owns the leaf list, so those modules are sub-packages of + * one registered leaf instead — the same resolution the JPA and GraphQL platforms reached. The + * design-module to package mapping is in {@code docs/web/repository-adaptation.md}. + * + *

That choice is only honest if the boundaries are machine checked, so this enum is the declared + * identity: each constant names a module, the package that carries it, whether it may touch the + * framework, and exactly which other modules it may import. {@code WebModuleBoundaryTest} scans the + * real source tree and fails when the tree and this declaration disagree in either direction — an + * undeclared edge, or a package with no declared identity. + * + *

Declaring the edge set is also what keeps the leaf split reversible: each constant is already + * shaped like a leaf specification, so promoting a module to its own Gradle path is a registry edit + * rather than an archaeology exercise. + */ +public enum WebStableModule { + + /** + * The operator's view of what the platform is actually running, and the startup check on it. + * + *

Sub-package of {@code admin} rather than its own module: it is the same audience and the + * same exposure decision as the route inventory that already lives there. + */ + ADMIN_PLATFORM("admin.platform", "admin.platform", WebModulePurity.CORE), + + /** The runtime route inventory and its release-gate comparison. */ + ADMIN_ROUTE( + "admin.route", + "admin.route", + WebModulePurity.FRAMEWORK_BOUND, + "core", + "operation", + "versioning"), + + /** + * Load shedding: bounded concurrency, a bounded queue, and the 503 that follows. + * + *

CORE, and separate from {@code ratelimit} on purpose. This module knows what the service is + * doing and nothing about who is calling; that separation is what keeps a capacity 503 from being + * reported as a quota 429. + */ + ADMISSION("admission", "admission", WebModulePurity.CORE, "operation"), + + /** Authenticated principal, entry point and denial writers for the HTTP boundary. */ + AUTH("auth", "auth", WebModulePurity.FRAMEWORK_BOUND, "error", "observability", "settings"), + + /** Permission evaluation and method security wiring. */ + AUTHZ("authz", "authz", WebModulePurity.FRAMEWORK_BOUND, "auth"), + + /** Request and response hard bounds, their named profiles and the catalog that holds them. */ + BUDGET("budget", "budget", WebModulePurity.CORE), + + /** ETag values and precondition failures: conditional request semantics, no transport. */ + CONDITIONAL("conditional", "conditional", WebModulePurity.CORE, "operation"), + + /** + * Identifiers, request context and the value types every other module is allowed to speak. + * + *

The design's {@code web-core-api}: no Servlet, Spring MVC, WebFlux, Reactor or Jackson, so + * an HTTP semantic can be exercised by a unit test and reused by either transport. + */ + CORE("core", "core", WebModulePurity.CORE), + + /** The fixed JSON representation of every type whose default rendering is wrong for an API. */ + CONTRACT("contract", "contract", WebModulePurity.CORE), + + /** + * The published cache profiles, their directives and the {@code Vary} rule. + * + *

CORE: a caching decision is a set of directives, not a framework call. Keeping it free of + * Spring is what lets the same profile be applied by the servlet writer, the reactive writer and + * the OpenAPI document without three renderings of it. + */ + CACHE("cache", "cache", WebModulePurity.CORE), + + /** Spring configuration classes that assemble the transport. */ + CONFIG("config", "config", WebModulePurity.FRAMEWORK_BOUND, "settings"), + + /** Skeleton controllers shipped by the platform itself. */ + CONTROLLER("controller", "controller", WebModulePurity.FRAMEWORK_BOUND, "observability"), + + /** Opaque keyset cursor encoding and its failure type. */ + CURSOR("cursor", "cursor", WebModulePurity.CORE), + + /** Response envelope advice. */ + ENVELOPE("envelope", "envelope", WebModulePurity.FRAMEWORK_BOUND, "observability"), + + /** Error translation: the single place a failure becomes an HTTP status and a body. */ + ERROR( + "error", + "error", + WebModulePurity.FRAMEWORK_BOUND, + "admission", + "auth", + "budget", + "conditional", + "cursor", + "http", + "observability", + "pagination"), + + /** The three-axis execution evidence model: admission, application and response. */ + EVIDENCE("evidence", "evidence", WebModulePurity.CORE), + + /** File server admin endpoints. */ + FILESERVER_ADMIN( + "fileserver.admin", + "fileserver.admin", + WebModulePurity.FRAMEWORK_BOUND, + "fileserver.dto", + "fileserver.security"), + + /** File server transport configuration. */ + FILESERVER_CONFIG("fileserver.config", "fileserver.config", WebModulePurity.FRAMEWORK_BOUND), + + /** File server HTTP controllers. */ + FILESERVER_CONTROLLER( + "fileserver.controller", + "fileserver.controller", + WebModulePurity.FRAMEWORK_BOUND, + "fileserver.config", + "fileserver.dto", + "fileserver.http", + "fileserver.mapper", + "fileserver.nginx", + "fileserver.security"), + + /** tus draft-12 resumable upload surface. */ + FILESERVER_DRAFT12( + "fileserver.draft12", + "fileserver.draft12", + WebModulePurity.FRAMEWORK_BOUND, + "fileserver.config", + "fileserver.security"), + + /** File server wire DTOs. */ + FILESERVER_DTO("fileserver.dto", "fileserver.dto", WebModulePurity.FRAMEWORK_BOUND), + + /** File server HTTP header and range helpers. */ + FILESERVER_HTTP("fileserver.http", "fileserver.http", WebModulePurity.FRAMEWORK_BOUND), + + /** File server lifecycle endpoints. */ + FILESERVER_LIFECYCLE( + "fileserver.lifecycle", + "fileserver.lifecycle", + WebModulePurity.FRAMEWORK_BOUND, + "fileserver.dto", + "fileserver.security"), + + /** File server DTO mapping. */ + FILESERVER_MAPPER("fileserver.mapper", "fileserver.mapper", WebModulePurity.FRAMEWORK_BOUND), + + /** Nginx offload headers for the file server. */ + FILESERVER_NGINX("fileserver.nginx", "fileserver.nginx", WebModulePurity.FRAMEWORK_BOUND), + + /** File server problem responses. */ + FILESERVER_PROBLEM( + "fileserver.problem", "fileserver.problem", WebModulePurity.FRAMEWORK_BOUND, "observability"), + + /** Reactive file server surface. */ + FILESERVER_REACTIVE( + "fileserver.reactive", + "fileserver.reactive", + WebModulePurity.FRAMEWORK_BOUND, + "fileserver.config", + "fileserver.dto", + "fileserver.mapper", + "fileserver.problem", + "fileserver.security"), + + /** File server authorization at the HTTP boundary. */ + FILESERVER_SECURITY( + "fileserver.security", + "fileserver.security", + WebModulePurity.FRAMEWORK_BOUND, + "auth", + "observability"), + + /** tus resumable upload surface. */ + FILESERVER_TUS( + "fileserver.tus", + "fileserver.tus", + WebModulePurity.FRAMEWORK_BOUND, + "fileserver.config", + "fileserver.security"), + + /** Servlet filters: the ordered entry seam of the request path. */ + FILTER("filter", "filter", WebModulePurity.FRAMEWORK_BOUND, "auth", "http", "observability"), + + /** Header names and HTTP constants shared across the platform. */ + HTTP("http", "http", WebModulePurity.CORE, "operation", "proxy"), + + /** + * Idempotency admission, key handling, semantic fingerprinting and the stored response codec. + * + *

The decision half of the design's idempotency module, deliberately holding no transport + * type: {@code mvc.idempotency} and {@code webflux.idempotency} carry the two bindings, and both + * defer to the plan this module computes so the two stacks cannot answer the same collision + * differently. + */ + IDEMPOTENCY( + "idempotency", + "idempotency", + WebModulePurity.FRAMEWORK_BOUND, + "auth", + "core", + "error", + "http", + "json", + "operation"), + + /** Notification provider callback intake. */ + NOTIFICATION_CALLBACK( + "notification.platform.callback", + "notification.platform.callback", + WebModulePurity.FRAMEWORK_BOUND), + + /** Reactive notification provider callback intake. */ + NOTIFICATION_CALLBACK_REACTIVE( + "notification.platform.callback.reactive", + "notification.platform.callback.reactive", + WebModulePurity.FRAMEWORK_BOUND, + "notification.platform.callback"), + + /** Notification submission endpoints. */ + NOTIFICATION_SUBMISSION( + "notification.platform.submission", + "notification.platform.submission", + WebModulePurity.FRAMEWORK_BOUND, + "observability"), + + /** The declared module map itself: identities, purity grades and allowed edges. */ + MODULE_BOUNDARY("moduleboundary", "moduleboundary", WebModulePurity.CORE), + + /** Operation profiles: what an operation does to state and which policies govern it. */ + OPERATION("operation", "operation", WebModulePurity.CORE, "budget", "core"), + + /** The strict JSON reader: the one place a request body's leniency is decided. */ + JSON("json", "json", WebModulePurity.FRAMEWORK_BOUND, "contract"), + + /** + * The servlet transport: the filters, the context holder and the auto-configuration root. + * + *

The design's {@code web-mvc} and {@code web-spring-boot-starter-mvc} together. They are one + * module here because the split in the design is a packaging boundary — an adopter choosing MVC + * takes both — and two packages that always ship together buy a boundary nobody can violate. + */ + MVC( + "mvc", + "mvc", + WebModulePurity.FRAMEWORK_BOUND, + "admission", + "budget", + "contract", + "core", + "error", + "evidence", + "http", + "idempotency", + "json", + "operation", + "operationasync", + "ratelimit", + "validation"), + + /** + * Durable long-running operations: the polled resource, its lifecycle and the store port. + * + *

FRAMEWORK_BOUND only because it names {@code error}'s problem document, which is. Nothing + * here touches a servlet, a scheduler or a persistence type — an operation that a client polls + * must outlive the process that accepted it, and a process-local future would not. + */ + OPERATION_ASYNC( + "operationasync", "operationasync", WebModulePurity.FRAMEWORK_BOUND, "core", "error"), + + /** The published OpenAPI document, its platform components and the release gate. */ + OPENAPI("openapi", "openapi", WebModulePurity.FRAMEWORK_BOUND, "error"), + + /** Correlation identifiers, MDC keys, header sanitising and response metadata. */ + OBSERVABILITY("observability", "observability", WebModulePurity.FRAMEWORK_BOUND), + + /** Page and sort request values with their validation failure. */ + PAGINATION("pagination", "pagination", WebModulePurity.CORE), + + /** Trusted proxy policy and forwarded header normalisation. */ + PROXY("proxy", "proxy", WebModulePurity.CORE), + + /** Client identity resolution and the edge rate limit transport bridge. */ + RATELIMIT( + "ratelimit", "ratelimit", WebModulePurity.FRAMEWORK_BOUND, "auth", "core", "error", "http"), + + /** + * Transport validation: the 400/422 split and the mapping into publishable issues. + * + *

Framework bound because Bean Validation and Jackson are where the failures come from, not + * because the decision needs them. + */ + VALIDATION("validation", "validation", WebModulePurity.FRAMEWORK_BOUND, "error", "json"), + + /** + * The reactive transport: the Reactor Context filter, the blocking guard and its root. + * + *

The design's {@code web-webflux} and {@code web-spring-boot-starter-webflux}. It declares no + * edge to {@code mvc}: the two transports are mutually exclusive, and an edge between them would + * make the reactive starter compile against servlet types. + */ + WEBFLUX( + "webflux", + "webflux", + WebModulePurity.FRAMEWORK_BOUND, + "admission", + "budget", + "core", + "error", + "evidence", + "http", + "idempotency", + "json", + "operation", + "operationasync", + "ratelimit", + "validation"), + + /** + * The bridge from a verified authentication to the platform's actor and tenant. + * + *

CORE: it holds no Spring Security type. That is what keeps token verification out of the web + * layer — there is nothing here to verify a token with. + */ + SECURITY("security", "security", WebModulePurity.CORE, "core"), + + /** Path major versioning, the served version catalog and the deprecation notice. */ + VERSIONING("versioning", "versioning", WebModulePurity.CORE, "core", "http"), + + /** Bound configuration properties for the transport. */ + SETTINGS("settings", "settings", WebModulePurity.FRAMEWORK_BOUND), + + /** + * The Advanced capabilities, each behind its own feature flag. + * + *

One module rather than the design's eleven Gradle modules, for the reason the Stable + * platform is one leaf: the registry owns the leaf list and a directory layout is not worth + * eleven entries in it. What the design wanted from the separation — that Stable never depends on + * Advanced — is enforced by ArchUnit, which a flag cannot do and a module boundary can. + */ + ADVANCED("advanced", "advanced", WebModulePurity.CORE, "core", "error", "operation"), + + /** + * The Advanced pieces that must name a framework type. + * + *

Separated from {@code advanced} rather than relaxing its purity, because most of Advanced is + * pure policy and only the patch appliers, the codec factories and the negotiation seam are not. + * Jackson's tree model is the reason: a merge patch's null-means-delete has no representation in + * a Java object, so the applier has to work on nodes. + */ + ADVANCED_PATCH( + "advanced-patch", "advanced.patch", WebModulePurity.FRAMEWORK_BOUND, "advanced", "error"), + + /** The CBOR and XML representations, and the negotiation that gates them. */ + ADVANCED_CODEC( + "advanced-codec", "advanced.codec", WebModulePurity.FRAMEWORK_BOUND, "advanced", "error"), + + /** The stream framing writers, which serialize and therefore bind to Jackson. */ + ADVANCED_STREAM_ENCODING( + "advanced-stream-encoding", + "advanced.stream.encoding", + WebModulePurity.FRAMEWORK_BOUND, + "advanced", + "advanced-stream"), + + /** The streaming core: envelopes, evidence, policy, termination, registry and drain. */ + ADVANCED_STREAM("advanced-stream", "advanced.stream", WebModulePurity.CORE, "advanced", "error"), + + /** + * Functional WebFlux routes, checked against the operation catalog. + * + *

{@code FRAMEWORK_BOUND} because the router it builds is Spring's. The validation above it is + * pure and could have stayed so, but splitting a four-class package to keep one of them + * frameworkless would trade a real boundary for a nominal one. + */ + ADVANCED_FUNCTIONAL( + "advanced-functional", + "advanced.functional", + WebModulePurity.FRAMEWORK_BOUND, + "advanced", + "core", + "operation"), + + /** The virtual-thread execution profile and the admission limit it must not remove. */ + ADVANCED_VIRTUAL_THREAD( + "advanced-virtual-thread", "advanced.virtualthread", WebModulePurity.CORE, "advanced"), + + /** The bounded blocking offload for WebFlux. */ + ADVANCED_BLOCKING_BRIDGE( + "advanced-blocking-bridge", "advanced.blockingbridge", WebModulePurity.CORE, "advanced"), + + /** + * The parallel OpenAPI 3.2 lane. + * + *

{@code FRAMEWORK_BOUND} because generating a document means holding swagger's model, and + * copying it faithfully means using swagger's own serializer. The edge to the stream encoding is + * the streaming media types: the 3.2 difference this lane exists to report is the per-item schema + * on exactly those responses, so the generator has to know which they are. + */ + ADVANCED_OPENAPI( + "advanced-openapi", + "advanced.openapi", + WebModulePurity.FRAMEWORK_BOUND, + "advanced", + "advanced-stream-encoding"), + + /** The draft RateLimit response headers. */ + ADVANCED_RATELIMIT( + "advanced-ratelimit", "advanced.ratelimit", WebModulePurity.CORE, "advanced", "ratelimit"), + + /** The promotion gate and the release evidence manifest. */ + ADVANCED_RELEASE("advanced-release", "advanced.release", WebModulePurity.CORE, "advanced"), + + /** + * The servlet execution of the Advanced capabilities: SSE emitters, the streaming writers, the + * disconnect detector and the virtual-thread executor. + * + *

Separated from the pure Advanced modules for the reason Stable's {@code mvc} is separated + * from its policy modules: this is where the framework actually is. Everything above it decides; + * this writes bytes to a servlet response. + */ + ADVANCED_MVC( + "advanced-mvc", + "advanced.mvc", + WebModulePurity.FRAMEWORK_BOUND, + "advanced", + "advanced-stream", + "advanced-stream-encoding", + "advanced-virtual-thread", + "core", + "error", + "evidence", + "json"), + + /** The reactive execution of the same capabilities, plus the bounded blocking offload. */ + ADVANCED_WEBFLUX( + "advanced-webflux", + "advanced.webflux", + WebModulePurity.FRAMEWORK_BOUND, + "advanced", + "advanced-blocking-bridge", + "advanced-stream", + "advanced-stream-encoding", + "core", + "error", + "evidence", + "json"); + + private final String id; + private final String packageSuffix; + private final WebModulePurity purity; + + /** + * Populated only from {@link Set#of}, which is genuinely immutable. Error Prone's {@code + * ImmutableEnumChecker} recognises Guava's {@code ImmutableSet} but not the JDK's unmodifiable + * factories, and this leaf has no Guava dependency to add for one field. + */ + @SuppressWarnings("ImmutableEnumChecker") + private final Set allowedDependencies; + + WebStableModule( + String id, String packageSuffix, WebModulePurity purity, String... allowedDependencies) { + this.id = id; + this.packageSuffix = packageSuffix; + this.purity = purity; + this.allowedDependencies = Set.of(allowedDependencies); + } + + /** The module identifier used on both sides of a declared dependency edge. */ + public String id() { + return id; + } + + /** The fully qualified package that carries this module, including its sub-packages. */ + public String packageName() { + return packageSuffix.isEmpty() + ? WebModuleBoundary.PACKAGE_ROOT + : WebModuleBoundary.PACKAGE_ROOT + "." + packageSuffix; + } + + /** Whether this module may reference framework types. */ + public WebModulePurity purity() { + return purity; + } + + /** The module identifiers this module is allowed to import. */ + public Set allowedDependencies() { + return allowedDependencies; + } + + /** Every declared Stable module identifier. */ + public static Set moduleIds() { + return Arrays.stream(values()).map(WebStableModule::id).collect(Collectors.toUnmodifiableSet()); + } + + /** Every declared Stable module that must not reference a framework type. */ + public static Set coreModuleIds() { + return Arrays.stream(values()) + .filter(module -> module.purity() == WebModulePurity.CORE) + .map(WebStableModule::id) + .collect(Collectors.toUnmodifiableSet()); + } + + /** Every declared Stable dependency edge, in deterministic order. */ + public static Map> dependencyEdges() { + Map> edges = new TreeMap<>(); + for (WebStableModule module : values()) { + edges.put(module.id(), module.allowedDependencies()); + } + return Map.copyOf(edges); + } + + /** The declared package of every Stable module, keyed by identifier. */ + public static Map packagesById() { + Map packages = new LinkedHashMap<>(); + for (WebStableModule module : values()) { + packages.put(module.id(), module.packageName()); + } + return Map.copyOf(packages); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/mvc/autoconfigure/WebMvcPlatformAutoConfiguration.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/mvc/autoconfigure/WebMvcPlatformAutoConfiguration.java new file mode 100644 index 00000000..391d8bf9 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/mvc/autoconfigure/WebMvcPlatformAutoConfiguration.java @@ -0,0 +1,168 @@ +package dev.caskeleton.adapter.inbound.web.mvc.autoconfigure; + +import dev.caskeleton.adapter.inbound.web.budget.WebBudgetCatalog; +import dev.caskeleton.adapter.inbound.web.budget.WebBudgetProfileName; +import dev.caskeleton.adapter.inbound.web.budget.WebRequestBudget; +import dev.caskeleton.adapter.inbound.web.contract.WebWireTypeManifest; +import dev.caskeleton.adapter.inbound.web.error.ProblemCatalog; +import dev.caskeleton.adapter.inbound.web.error.WebProblemFactory; +import dev.caskeleton.adapter.inbound.web.error.WebProblemSanitizer; +import dev.caskeleton.adapter.inbound.web.http.WebMethodPolicy; +import dev.caskeleton.adapter.inbound.web.http.WebUriPolicy; +import dev.caskeleton.adapter.inbound.web.json.WebJsonProfile; +import dev.caskeleton.adapter.inbound.web.json.WebObjectMapperFactory; +import dev.caskeleton.adapter.inbound.web.mvc.context.WebMvcRequestContextArgumentResolver; +import dev.caskeleton.adapter.inbound.web.mvc.filter.WebMvcEvidenceFilter; +import dev.caskeleton.adapter.inbound.web.mvc.filter.WebMvcRequestIdFilter; +import dev.caskeleton.adapter.inbound.web.operation.InMemoryWebOperationCatalog; +import dev.caskeleton.adapter.inbound.web.operation.WebOperationCatalog; +import dev.caskeleton.adapter.inbound.web.validation.WebValidationExceptionMapper; +import dev.caskeleton.adapter.inbound.web.validation.WebValidationIssueMapper; +import java.util.List; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +/** + * Wires the web platform into a servlet application. + * + *

One root, master-gated, following the pattern the other five adapters in this repository were + * corrected onto: the condition lives here and everything else is imported, so "off" means no beans + * rather than beans that happen not to be called. + * + *

{@link ConditionalOnWebApplication} with {@code SERVLET} is what keeps this starter and the + * WebFlux one mutually exclusive at the type level — a reactive application cannot accidentally + * activate the servlet filters even if both artifacts are on the classpath. + * + *

Every bean is {@link ConditionalOnMissingBean}. An adopter that registers its own operation + * catalog gets theirs; the platform supplies the one that would otherwise be missing rather than + * the one that must be used. + */ +@AutoConfiguration +@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) +@ConditionalOnProperty( + prefix = "backend.web.mvc", + name = "enabled", + havingValue = "true", + matchIfMissing = true) +@EnableConfigurationProperties(WebMvcPlatformSettings.class) +public class WebMvcPlatformAutoConfiguration { + + /** The catalog of registered operations; empty until routes register themselves. */ + @Bean + @ConditionalOnMissingBean(WebOperationCatalog.class) + public InMemoryWebOperationCatalog webOperationCatalog() { + return new InMemoryWebOperationCatalog(); + } + + /** The registered request budget profiles, with the conservative standard profile present. */ + @Bean + @ConditionalOnMissingBean + public WebBudgetCatalog webBudgetCatalog() { + WebBudgetCatalog catalog = new WebBudgetCatalog(); + catalog.register(WebBudgetProfileName.standard(), WebRequestBudget.standard()); + return catalog; + } + + /** The published problem catalog. */ + @Bean + @ConditionalOnMissingBean + public ProblemCatalog webProblemCatalog() { + return ProblemCatalog.standard(); + } + + /** + * The only builder of problem bodies. + * + *

Registered regardless of {@code spring.mvc.problemdetails.enabled}. That flag decides + * whether Spring writes its own {@code ProblemDetail} for framework exceptions; this factory owns + * the platform's wire contract either way, so the two cannot produce differently shaped errors + * for the same API. + */ + @Bean + @ConditionalOnMissingBean + public WebProblemFactory webProblemFactory(ProblemCatalog catalog) { + return new WebProblemFactory(catalog, new WebProblemSanitizer()); + } + + /** The 400/422 classifier. */ + @Bean + @ConditionalOnMissingBean + public WebValidationExceptionMapper webValidationExceptionMapper() { + return new WebValidationExceptionMapper(); + } + + /** The Bean Validation to JSON Pointer mapper. */ + @Bean + @ConditionalOnMissingBean + public WebValidationIssueMapper webValidationIssueMapper() { + return new WebValidationIssueMapper(); + } + + /** The fixed JSON representation of the types whose defaults are wrong for an API. */ + @Bean + @ConditionalOnMissingBean + public WebWireTypeManifest webWireTypeManifest() { + return WebWireTypeManifest.standard(); + } + + /** The one accepted path spelling. */ + @Bean + @ConditionalOnMissingBean + public WebUriPolicy webUriPolicy() { + return WebUriPolicy.standard(); + } + + /** The method allowlist. */ + @Bean + @ConditionalOnMissingBean + public WebMethodPolicy webMethodPolicy() { + return WebMethodPolicy.standard(); + } + + /** The strict reader request bodies are read with. */ + @Bean + @ConditionalOnMissingBean(name = "webStrictObjectMapper") + public tools.jackson.databind.json.JsonMapper webStrictObjectMapper( + WebMvcPlatformSettings properties) { + // A JsonMapper, not an ObjectMapper: Spring 7's JacksonJsonHttpMessageConverter takes the + // concrete Jackson 3 type, so anything else is a bean the framework never reads a body with. + return WebObjectMapperFactory.jsonMapper( + properties.strictJson() + ? WebJsonProfile.strict() + : WebJsonProfile.tolerantOfUnknownProperties()); + } + + /** Assigns the correlation identifiers, validating anything a caller proposed. */ + @Bean + @ConditionalOnMissingBean + public WebMvcRequestIdFilter webMvcRequestIdFilter(WebMvcPlatformSettings properties) { + return new WebMvcRequestIdFilter( + properties.trustInboundRequestId(), + org.springframework.core.Ordered.HIGHEST_PRECEDENCE + 10); + } + + /** Attaches one evidence tracker per logical request. */ + @Bean + @ConditionalOnMissingBean + public WebMvcEvidenceFilter webMvcEvidenceFilter() { + return new WebMvcEvidenceFilter(); + } + + /** Lets a controller declare the request context instead of the servlet request. */ + @Bean + @ConditionalOnMissingBean(name = "webMvcRequestContextConfigurer") + public WebMvcConfigurer webMvcRequestContextConfigurer() { + return new WebMvcConfigurer() { + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(new WebMvcRequestContextArgumentResolver()); + } + }; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/mvc/autoconfigure/WebMvcPlatformSettings.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/mvc/autoconfigure/WebMvcPlatformSettings.java new file mode 100644 index 00000000..e7a62fb1 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/mvc/autoconfigure/WebMvcPlatformSettings.java @@ -0,0 +1,37 @@ +package dev.caskeleton.adapter.inbound.web.mvc.autoconfigure; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * The bound settings of the MVC web platform. + * + *

Named {@code Settings} rather than the design's {@code Properties} because this repository's + * naming convention test requires every {@code @ConfigurationProperties} type to end in {@code + * Settings} or {@code Policy}, and a convention with one exception is a convention nobody can rely + * on. + * + *

Every default here is the safe one, so a deployment that configures nothing gets the strict + * behaviour. The opposite convention — permissive defaults with a documented switch — means the + * deployments that never read the documentation are exactly the ones running without the control. + * + * @param enabled whether the platform's MVC wiring is installed at all + * @param trustInboundRequestId whether a caller may choose its own request id + * @param strictJson whether request bodies are read with the strict profile + */ +@ConfigurationProperties(prefix = "backend.web.mvc") +public record WebMvcPlatformSettings( + Boolean enabled, Boolean trustInboundRequestId, Boolean strictJson) { + + public WebMvcPlatformSettings { + enabled = enabled == null || enabled; + // Defaults to false: a caller that can choose its own request id can make two exchanges share + // one identity, which is how a support investigation reads somebody else's request. + trustInboundRequestId = trustInboundRequestId != null && trustInboundRequestId; + strictJson = strictJson == null || strictJson; + } + + /** The defaults a deployment that configures nothing runs with. */ + public static WebMvcPlatformSettings defaults() { + return new WebMvcPlatformSettings(null, null, null); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/mvc/budget/BoundedHttpServletRequest.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/mvc/budget/BoundedHttpServletRequest.java new file mode 100644 index 00000000..37c6eaaa --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/mvc/budget/BoundedHttpServletRequest.java @@ -0,0 +1,115 @@ +package dev.caskeleton.adapter.inbound.web.mvc.budget; + +import dev.caskeleton.adapter.inbound.web.budget.WebBudgetMeter; +import jakarta.servlet.ReadListener; +import jakarta.servlet.ServletInputStream; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletRequestWrapper; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.Objects; + +/** + * A request whose body cannot exceed its budget. + * + *

Wrapping the stream rather than reading the body first is the requirement, not a refinement: a + * check that reads the body to measure it hands an attacker the heap exhaustion the check exists to + * prevent. Here the bound is crossed while the bytes are still arriving, and the read fails at that + * byte. + * + *

{@code getReader} shares the same meter through the same stream, so a handler that reads text + * is bounded exactly like one that reads bytes. A wrapper that bounded only {@code getInputStream} + * would be bypassed by every {@code @RequestBody String}. + */ +public final class BoundedHttpServletRequest extends HttpServletRequestWrapper { + + private final WebBudgetMeter meter; + private ServletInputStream boundedStream; + private BufferedReader boundedReader; + + /** + * A bounded view of one request. + * + * @param request the request being served + * @param meter the body budget + */ + public BoundedHttpServletRequest(HttpServletRequest request, WebBudgetMeter meter) { + super(request); + this.meter = Objects.requireNonNull(meter, "meter"); + // Refused before a byte is read when the caller declared an oversized body. Cheap, honest, and + // never trusted on its own — a chunked or lying request has no Content-Length to check. + meter.declared(request.getContentLengthLong()); + } + + @Override + public ServletInputStream getInputStream() throws IOException { + if (boundedStream == null) { + boundedStream = new BoundedServletInputStream(super.getInputStream(), meter); + } + return boundedStream; + } + + @Override + public BufferedReader getReader() throws IOException { + if (boundedReader == null) { + Charset charset = + getCharacterEncoding() == null + ? StandardCharsets.UTF_8 + : Charset.forName(getCharacterEncoding()); + boundedReader = new BufferedReader(new InputStreamReader(getInputStream(), charset)); + } + return boundedReader; + } + + private static final class BoundedServletInputStream extends ServletInputStream { + + private final ServletInputStream delegate; + private final WebBudgetMeter meter; + + private BoundedServletInputStream(ServletInputStream delegate, WebBudgetMeter meter) { + this.delegate = delegate; + this.meter = meter; + } + + @Override + public int read() throws IOException { + int value = delegate.read(); + if (value != -1) { + meter.add(1); + } + return value; + } + + @Override + public int read(byte[] buffer, int offset, int length) throws IOException { + int read = delegate.read(buffer, offset, length); + if (read > 0) { + meter.add(read); + } + return read; + } + + @Override + public boolean isFinished() { + return delegate.isFinished(); + } + + @Override + public boolean isReady() { + return delegate.isReady(); + } + + @Override + public void setReadListener(ReadListener readListener) { + delegate.setReadListener(readListener); + } + + @Override + public void close() throws IOException { + delegate.close(); + } + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/mvc/budget/BoundedHttpServletResponse.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/mvc/budget/BoundedHttpServletResponse.java new file mode 100644 index 00000000..8ba1739a --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/mvc/budget/BoundedHttpServletResponse.java @@ -0,0 +1,151 @@ +package dev.caskeleton.adapter.inbound.web.mvc.budget; + +import dev.caskeleton.adapter.inbound.web.budget.WebBudgetMeter; +import jakarta.servlet.ServletOutputStream; +import jakarta.servlet.WriteListener; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpServletResponseWrapper; +import java.io.IOException; +import java.io.PrintWriter; +import java.nio.charset.StandardCharsets; +import java.util.Objects; + +/** + * A response that cannot exceed its budget. + * + *

What happens on the way out depends on whether the response has been committed, and the two + * cases are genuinely different: + * + *

    + *
  • Not yet committed — nothing has reached the client, so the overrun can still be answered as + * a problem document. That is the good case and the platform should reach it. + *
  • Already committed — the status and part of the body are on the wire. There is no way to + * take them back and no way to send a different status, so the only honest end is to stop + * writing and let the connection die mid-document. A truncated response the client rejects is + * better than a silently short one it accepts as complete. + *
+ */ +public final class BoundedHttpServletResponse extends HttpServletResponseWrapper { + + private final WebBudgetMeter meter; + private ServletOutputStream boundedStream; + private PrintWriter boundedWriter; + + /** + * A bounded view of one response. + * + * @param response the response being written + * @param meter the response budget + */ + public BoundedHttpServletResponse(HttpServletResponse response, WebBudgetMeter meter) { + super(response); + this.meter = Objects.requireNonNull(meter, "meter"); + } + + /** Whether the overrun can still be answered with a problem document. */ + public boolean canStillAnswer() { + return !isCommitted(); + } + + /** + * Discards the buffered response and everything counted for it. + * + *

Both, together. {@code reset} on its own leaves the meter holding the count of bytes that + * were just thrown away, so the small problem document replacing them is refused for a budget the + * client never received a byte of. + */ + @Override + public void reset() { + super.reset(); + meter.reset(); + boundedStream = null; + boundedWriter = null; + } + + @Override + public void resetBuffer() { + super.resetBuffer(); + meter.reset(); + } + + @Override + public void setContentLengthLong(long length) { + // The one chance to refuse before writing anything: a handler that knows its size says so + // here, and an oversized answer is turned into a problem while the response is still empty. + meter.declared(length); + super.setContentLengthLong(length); + } + + @Override + public void setContentLength(int length) { + meter.declared(length); + super.setContentLength(length); + } + + @Override + public ServletOutputStream getOutputStream() throws IOException { + if (boundedStream == null) { + boundedStream = new BoundedServletOutputStream(super.getOutputStream(), meter); + } + return boundedStream; + } + + @Override + public PrintWriter getWriter() throws IOException { + if (boundedWriter == null) { + boundedWriter = new PrintWriter(new java.io.OutputStreamWriter(getOutputStream(), charset())); + } + return boundedWriter; + } + + private java.nio.charset.Charset charset() { + return getCharacterEncoding() == null + ? StandardCharsets.UTF_8 + : java.nio.charset.Charset.forName(getCharacterEncoding()); + } + + private static final class BoundedServletOutputStream extends ServletOutputStream { + + private final ServletOutputStream delegate; + private final WebBudgetMeter meter; + + private BoundedServletOutputStream(ServletOutputStream delegate, WebBudgetMeter meter) { + this.delegate = delegate; + this.meter = meter; + } + + @Override + public void write(int value) throws IOException { + meter.add(1); + delegate.write(value); + } + + @Override + public void write(byte[] buffer, int offset, int length) throws IOException { + // Counted before the write, not after: counting after would put the byte that crosses the + // bound on the wire and only then refuse. + meter.add(length); + delegate.write(buffer, offset, length); + } + + @Override + public boolean isReady() { + return delegate.isReady(); + } + + @Override + public void setWriteListener(WriteListener writeListener) { + delegate.setWriteListener(writeListener); + } + + @Override + public void flush() throws IOException { + delegate.flush(); + } + + @Override + public void close() throws IOException { + delegate.close(); + } + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/mvc/budget/WebMvcBudgetExceptionHandler.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/mvc/budget/WebMvcBudgetExceptionHandler.java new file mode 100644 index 00000000..ea75c7ef --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/mvc/budget/WebMvcBudgetExceptionHandler.java @@ -0,0 +1,84 @@ +package dev.caskeleton.adapter.inbound.web.mvc.budget; + +import dev.caskeleton.adapter.inbound.web.budget.WebBudgetExceededException; +import dev.caskeleton.adapter.inbound.web.error.BudgetProblemMapper; +import dev.caskeleton.adapter.inbound.web.error.WebProblem; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.net.URI; +import java.util.Objects; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +/** + * Answers a budget crossed inside the handler. + * + *

Needed alongside the filter, not instead of it, because the two catch different moments. The + * filter sees the cheap dimensions before dispatch and the response overrun after; a body bound + * crossed while the handler is reading the stream is thrown *inside* the dispatcher, which resolves + * it into a 500 before the filter's catch is ever reached. That is how the first draft of this + * feature answered 500 to an oversized chunked body while its unit tests were green. + * + *

Both paths produce the document through the same {@link BudgetProblemMapper}, so which one + * fired is invisible to a client. + */ +@RestControllerAdvice +// Mirrors every condition on the auto-configuration that supplies its WebProblemFactory. Only +// half of them was not enough: the all-off deployment is not a servlet application at all, so the +// factory is absent while the property condition still matched, and the context failed to start on +// an unsatisfied dependency. +@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) +// Gated on a property rather than on the BudgetProblemMapper bean, for the same reason: on a +// component-scanned type @ConditionalOnBean is evaluated before the configuration that declares +// the bean has necessarily run, so the handler disappears without a word. +@ConditionalOnProperty(prefix = "backend.web.budgets", name = "enabled", havingValue = "true") +@Order(Ordered.HIGHEST_PRECEDENCE) +public class WebMvcBudgetExceptionHandler { + + private final BudgetProblemMapper problems; + + /** + * A handler over the budget problem mapper. + * + * @param problems renders a crossed bound as a problem document + */ + public WebMvcBudgetExceptionHandler(BudgetProblemMapper problems) { + this.problems = Objects.requireNonNull(problems, "problems"); + } + + /** + * Publishes a crossed bound. + * + *

The response is reset first, and that is load-bearing for the response-overrun case. That + * violation is thrown while the message converter is writing, so without the reset this advice + * would serialize the problem document back through a meter already over its limit — the write + * fails again and the client gets the container's own error page with no problem document at all. + * Resetting discards the partial body the client never received and, with it, the count of those + * bytes. + * + *

Once the response has committed there is nothing to reset and no status left to send. The + * exception is rethrown so the connection ends mid-document: a client that sees a truncated + * response knows something went wrong, and one that sees a short but well-formed one does not. + */ + @ExceptionHandler(WebBudgetExceededException.class) + public ResponseEntity handle( + WebBudgetExceededException exceeded, + HttpServletRequest request, + HttpServletResponse response) { + if (response.isCommitted()) { + throw exceeded; + } + response.reset(); + WebProblem problem = + problems.problemFor(exceeded, URI.create(request.getRequestURI()), "0".repeat(32)); + return ResponseEntity.status(problem.status()) + .contentType(MediaType.valueOf(WebProblem.MEDIA_TYPE)) + .body(problem); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/mvc/budget/WebMvcBudgetFilter.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/mvc/budget/WebMvcBudgetFilter.java new file mode 100644 index 00000000..d35a927c --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/mvc/budget/WebMvcBudgetFilter.java @@ -0,0 +1,122 @@ +package dev.caskeleton.adapter.inbound.web.mvc.budget; + +import dev.caskeleton.adapter.inbound.web.budget.WebBudgetExceededException; +import dev.caskeleton.adapter.inbound.web.budget.WebBudgetMeter; +import dev.caskeleton.adapter.inbound.web.budget.WebBudgetViolation; +import dev.caskeleton.adapter.inbound.web.budget.WebRequestBudget; +import dev.caskeleton.adapter.inbound.web.error.BudgetProblemMapper; +import dev.caskeleton.adapter.inbound.web.error.WebProblem; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.Objects; +import org.springframework.web.filter.OncePerRequestFilter; +import tools.jackson.databind.json.JsonMapper; + +/** + * Enforces the request and response budgets on the servlet stack. + * + *

Ordered before anything that reads the request, because a bound applied after the body has + * been parsed has already lost. The cheap dimensions — URI, headers, query count — are checked up + * front from values the container has already parsed; the expensive ones are enforced by the + * wrappers as bytes move. + */ +public final class WebMvcBudgetFilter extends OncePerRequestFilter { + + private final WebRequestBudget budget; + private final BudgetProblemMapper problems; + private final JsonMapper mapper; + + /** + * A filter over one budget. + * + * @param budget the bounds to enforce + * @param problems renders a crossed bound as a problem document + * @param mapper serializes that document + */ + public WebMvcBudgetFilter( + WebRequestBudget budget, BudgetProblemMapper problems, JsonMapper mapper) { + this.budget = Objects.requireNonNull(budget, "budget"); + this.problems = Objects.requireNonNull(problems, "problems"); + this.mapper = Objects.requireNonNull(mapper, "mapper"); + } + + @Override + protected void doFilterInternal( + HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws ServletException, IOException { + BoundedHttpServletResponse bounded = + new BoundedHttpServletResponse( + response, + new WebBudgetMeter(WebBudgetViolation.RESPONSE_TOO_LARGE, budget.maxResponseBytes())); + try { + checkCheapDimensions(request); + chain.doFilter( + new BoundedHttpServletRequest( + request, + new WebBudgetMeter(WebBudgetViolation.BODY_TOO_LARGE, budget.maxBodyBytes())), + bounded); + } catch (WebBudgetExceededException exceeded) { + if (!bounded.canStillAnswer()) { + // Committed. There is no status left to send and no way to retract what went out, so the + // response ends here, truncated. A client sees a broken document rather than a short one + // it would have accepted as whole. + throw new IOException("response budget exceeded after commit", exceeded); + } + writeProblem(request, response, exceeded); + } + } + + private void checkCheapDimensions(HttpServletRequest request) { + String uri = request.getRequestURI(); + String query = request.getQueryString(); + int uriBytes = + uri.getBytes(StandardCharsets.UTF_8).length + + (query == null ? 0 : query.getBytes(StandardCharsets.UTF_8).length + 1); + if (uriBytes > budget.maxUriBytes()) { + throw new WebBudgetExceededException( + WebBudgetViolation.URI_TOO_LONG, uriBytes, budget.maxUriBytes()); + } + + long headerBytes = 0; + for (String name : Collections.list(request.getHeaderNames())) { + for (String value : Collections.list(request.getHeaders(name))) { + // ": " and CRLF, so the measurement is of the wire form rather than of the parsed values. + headerBytes += name.length() + (value == null ? 0 : value.length()) + 4; + } + } + if (headerBytes > budget.maxHeaderBytes()) { + throw new WebBudgetExceededException( + WebBudgetViolation.HEADERS_TOO_LARGE, headerBytes, budget.maxHeaderBytes()); + } + + int parameters = request.getParameterMap().size(); + if (parameters > budget.maxQueryParameters()) { + throw new WebBudgetExceededException( + WebBudgetViolation.TOO_MANY_QUERY_PARAMETERS, parameters, budget.maxQueryParameters()); + } + } + + private void writeProblem( + HttpServletRequest request, HttpServletResponse response, WebBudgetExceededException exceeded) + throws IOException { + WebProblem problem = + problems.problemFor( + exceeded, + URI.create(request.getRequestURI()), + // The filter runs before the context resolver, so there is no trace id to borrow yet. + // A zero trace is honest about that; inventing one would put an identifier in a log + // that correlates with nothing. + "0".repeat(32)); + response.reset(); + response.setStatus(problems.statusFor(exceeded.violation())); + response.setContentType(WebProblem.MEDIA_TYPE); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + response.getWriter().write(mapper.writeValueAsString(problem)); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/mvc/context/WebMvcRequestContextArgumentResolver.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/mvc/context/WebMvcRequestContextArgumentResolver.java new file mode 100644 index 00000000..4a2ddb15 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/mvc/context/WebMvcRequestContextArgumentResolver.java @@ -0,0 +1,42 @@ +package dev.caskeleton.adapter.inbound.web.mvc.context; + +import dev.caskeleton.adapter.inbound.web.core.WebRequestContext; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.core.MethodParameter; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +/** + * Lets a controller declare {@link WebRequestContext} as a parameter. + * + *

This is what keeps {@code HttpServletRequest} out of controller signatures. A controller that + * takes the servlet request has to be given one by every test, cannot be called by another + * transport, and — the part that matters most here — can read any header it likes, which routes + * around the normalisation the platform did on the way in. + * + *

Resolution fails rather than returning null when no context is bound. A null actor reaching a + * use case is an authorization decision made by accident. + */ +public final class WebMvcRequestContextArgumentResolver implements HandlerMethodArgumentResolver { + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return WebRequestContext.class.equals(parameter.getParameterType()); + } + + @Override + public Object resolveArgument( + MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + WebDataBinderFactory binderFactory) { + HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class); + if (request == null) { + throw new IllegalStateException( + "no servlet request available to resolve the web request context from"); + } + return WebMvcRequestContextHolder.require(request); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/mvc/context/WebMvcRequestContextHolder.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/mvc/context/WebMvcRequestContextHolder.java new file mode 100644 index 00000000..2a32453e --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/mvc/context/WebMvcRequestContextHolder.java @@ -0,0 +1,66 @@ +package dev.caskeleton.adapter.inbound.web.mvc.context; + +import dev.caskeleton.adapter.inbound.web.core.WebRequestContext; +import jakarta.servlet.http.HttpServletRequest; +import java.util.Objects; +import java.util.Optional; + +/** + * Where the request context lives for the duration of one servlet request. + * + *

A request attribute rather than a {@code ThreadLocal}. The platform serves async dispatches + * and virtual threads, and both break the assumption a thread-local encodes: the thread that + * completes a response is not always the thread that started it, so a thread-local either loses the + * context or — worse, and this is the failure that is hard to see — hands the next request the + * previous one's actor. + * + *

The attribute travels with the request object, which is the thing that actually spans the + * dispatches. + */ +public final class WebMvcRequestContextHolder { + + /** Where the context lives. */ + public static final String CONTEXT_ATTRIBUTE = + WebMvcRequestContextHolder.class.getName() + ".context"; + + private WebMvcRequestContextHolder() {} + + /** + * Stores the context for this request. + * + * @throws IllegalStateException when a context is already present, because a second one would + * mean two stages disagree about who the caller is + */ + public static void store(HttpServletRequest request, WebRequestContext context) { + Objects.requireNonNull(request, "request"); + Objects.requireNonNull(context, "context"); + if (request.getAttribute(CONTEXT_ATTRIBUTE) != null) { + throw new IllegalStateException( + "a request context is already bound to this request; replacing it would let two stages" + + " disagree about the actor, the tenant and the deadline"); + } + request.setAttribute(CONTEXT_ATTRIBUTE, context); + } + + /** The context for this request, when one has been established. */ + public static Optional find(HttpServletRequest request) { + Objects.requireNonNull(request, "request"); + Object value = request.getAttribute(CONTEXT_ATTRIBUTE); + return value instanceof WebRequestContext context ? Optional.of(context) : Optional.empty(); + } + + /** + * The context for this request. + * + * @throws IllegalStateException when none was established, rather than fabricating an anonymous + * one that would make an unauthenticated request look like a deliberate decision + */ + public static WebRequestContext require(HttpServletRequest request) { + return find(request) + .orElseThrow( + () -> + new IllegalStateException( + "no web request context on this request; a fabricated one here would make an" + + " unauthenticated call look like an anonymous actor somebody chose")); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/mvc/error/WebMvcProblemExceptionHandler.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/mvc/error/WebMvcProblemExceptionHandler.java new file mode 100644 index 00000000..740cd79b --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/mvc/error/WebMvcProblemExceptionHandler.java @@ -0,0 +1,182 @@ +package dev.caskeleton.adapter.inbound.web.mvc.error; + +import dev.caskeleton.adapter.inbound.web.error.ProblemCode; +import dev.caskeleton.adapter.inbound.web.error.ValidationIssue; +import dev.caskeleton.adapter.inbound.web.error.WebProblem; +import dev.caskeleton.adapter.inbound.web.error.WebProblemFactory; +import dev.caskeleton.adapter.inbound.web.error.WebResourceNotVisibleException; +import dev.caskeleton.adapter.inbound.web.validation.WebValidationExceptionMapper; +import jakarta.servlet.http.HttpServletRequest; +import java.net.URI; +import java.util.List; +import java.util.Objects; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.HttpMediaTypeNotSupportedException; +import org.springframework.web.HttpRequestMethodNotSupportedException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.servlet.resource.NoResourceFoundException; + +/** + * Turns the framework's own transport failures into the platform's problem documents. + * + *

This is the piece that was missing, and its absence was invisible in the way that matters: + * {@code WebProblemFactory} and {@code ProblemCatalog} existed, were fully unit-tested, and were + * reached by nothing on the framework-error path. Spring answered a failed {@code @Valid} with its + * own {@code ProblemDetail} — RFC 9457-shaped, so it looked right — carrying no {@code code} field + * and a 400 where the platform's own 400/422 split says 422. Every client branching on {@code + * ProblemCode} would have found nothing to branch on, and no test of the catalog would ever have + * noticed. + * + *

Ordered ahead of Spring's {@code ResponseEntityExceptionHandler}, which handles all of these + * types and would otherwise win. + */ +@RestControllerAdvice +// Mirrors every condition on the auto-configuration that supplies its WebProblemFactory. Only +// half of them was not enough: the all-off deployment is not a servlet application at all, so the +// factory is absent while the property condition still matched, and the context failed to start on +// an unsatisfied dependency. +@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) +// Gated on the same property as the platform auto-configuration that supplies its +// WebProblemFactory, +// so the handler and its dependency appear and disappear together. +// +// Not @ConditionalOnBean: that condition is only reliable inside an auto-configuration class, where +// Boot controls the evaluation order. On a component-scanned type it is evaluated against whatever +// happens to be registered at that moment, and this handler silently vanished from every fixture +// that declared the factory in the very same configuration. +@ConditionalOnProperty( + prefix = "backend.web.mvc", + name = "enabled", + havingValue = "true", + matchIfMissing = true) +@Order(Ordered.HIGHEST_PRECEDENCE + 10) +public class WebMvcProblemExceptionHandler { + + private final WebProblemFactory problems; + private final WebValidationExceptionMapper validationMapper; + + /** + * A handler over the problem catalog. + * + * @param problems the problem document factory + */ + public WebMvcProblemExceptionHandler(WebProblemFactory problems) { + this.problems = Objects.requireNonNull(problems, "problems"); + this.validationMapper = new WebValidationExceptionMapper(); + } + + /** A bound request that violated a declared constraint: 422. */ + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleValidation( + MethodArgumentNotValidException failure, HttpServletRequest request) { + // 422, not 400. The document parsed and bound perfectly; what is wrong is what it says. A + // client told 400 looks for a syntax error it will not find. + List issues = + failure.getBindingResult().getFieldErrors().stream() + .map( + error -> + new ValidationIssue( + // RFC 6901 pointer, so a client can locate the field in the document it + // sent rather than parse a Java property path. + "/" + error.getField().replace('.', '/'), + error.getCode() == null ? "INVALID" : error.getCode(), + error.getDefaultMessage() == null + ? "is invalid" + : error.getDefaultMessage())) + .toList(); + return answer(ProblemCode.VALIDATION_FAILED, "the request failed validation", request, issues); + } + + /** A document that could not be read or could not be bound: 400. */ + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity handleUnreadable( + HttpMessageNotReadableException failure, HttpServletRequest request) { + // The split between "did not parse" and "parsed but would not bind" is the validation mapper's + // decision, not this handler's, so both stacks reach the same conclusion from the same code. + ProblemCode code = + validationMapper.find(failure.getMostSpecificCause()).orElse(ProblemCode.MALFORMED_REQUEST); + return answer(code, "the request body could not be read", request, List.of()); + } + + /** A media type this operation does not read: 415. */ + @ExceptionHandler(HttpMediaTypeNotSupportedException.class) + public ResponseEntity handleMediaType( + HttpMediaTypeNotSupportedException failure, HttpServletRequest request) { + return answer( + ProblemCode.UNSUPPORTED_MEDIA_TYPE, + "this operation does not read that media type", + request, + List.of()); + } + + /** A method this resource does not serve: 405. */ + @ExceptionHandler(HttpRequestMethodNotSupportedException.class) + public ResponseEntity handleMethod( + HttpRequestMethodNotSupportedException failure, HttpServletRequest request) { + WebProblem problem = + problems.create( + ProblemCode.METHOD_NOT_ALLOWED, + "this resource does not serve that method", + URI.create(request.getRequestURI()), + traceIdOf(request), + List.of()); + ResponseEntity.BodyBuilder builder = + ResponseEntity.status(problem.status()) + .contentType(MediaType.valueOf(WebProblem.MEDIA_TYPE)); + if (failure.getSupportedHttpMethods() != null) { + // RFC 9110 requires Allow on a 405. Omitting it leaves the client with no way to discover + // what the resource does serve except by guessing. + builder.header( + "Allow", + failure.getSupportedHttpMethods().stream() + .map(Object::toString) + .collect(java.util.stream.Collectors.joining(", "))); + } + return builder.body(problem); + } + + /** The resource is absent, or the caller may not know it exists: 404 either way. */ + @ExceptionHandler(WebResourceNotVisibleException.class) + public ResponseEntity handleNotVisible( + WebResourceNotVisibleException failure, HttpServletRequest request) { + return answer( + ProblemCode.RESOURCE_NOT_FOUND, + "no such " + failure.resourceKind() + " is visible", + request, + List.of()); + } + + /** Nothing is served at that path: 404. */ + @ExceptionHandler(NoResourceFoundException.class) + public ResponseEntity handleMissing( + NoResourceFoundException failure, HttpServletRequest request) { + return answer( + ProblemCode.RESOURCE_NOT_FOUND, "no resource is served at that path", request, List.of()); + } + + private ResponseEntity answer( + ProblemCode code, String detail, HttpServletRequest request, List issues) { + WebProblem problem = + problems.create( + code, detail, URI.create(request.getRequestURI()), traceIdOf(request), issues); + return ResponseEntity.status(problem.status()) + .contentType(MediaType.valueOf(WebProblem.MEDIA_TYPE)) + .body(problem); + } + + private static String traceIdOf(HttpServletRequest request) { + Object traceId = request.getAttribute("webTraceId"); + // A zero trace rather than a generated one. An identifier that correlates with nothing is + // worse than an obviously absent one: it sends an investigator looking for a trace that was + // never recorded. + return traceId == null ? "0".repeat(32) : traceId.toString(); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/mvc/filter/WebMvcEvidenceFilter.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/mvc/filter/WebMvcEvidenceFilter.java new file mode 100644 index 00000000..250ebee6 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/mvc/filter/WebMvcEvidenceFilter.java @@ -0,0 +1,114 @@ +package dev.caskeleton.adapter.inbound.web.mvc.filter; + +import dev.caskeleton.adapter.inbound.web.evidence.WebExecutionEvidenceTracker; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import org.springframework.core.Ordered; +import org.springframework.web.filter.OncePerRequestFilter; + +/** + * Attaches one evidence tracker to one logical request. + * + *

"Logical" is the word that matters. A servlet request that starts an async response is + * dispatched through the filter chain more than once, and a tracker created per dispatch would + * throw away what the first pass recorded — including, in the case this whole model exists for, + * that the application already committed. + * + *

{@link OncePerRequestFilter} alone does not solve that: its default is to skip async + * dispatches entirely, which would leave the redispatch with no tracker at all. So the filter opts + * into async dispatches and reuses the tracker it finds, which is why {@link + * #shouldNotFilterAsyncDispatch()} returns false. + * + *

The filter never touches the request body. Reading it here would consume the stream the + * controller is about to bind from, and buffering it to avoid that would put every request's body + * in memory to serve a concern that does not need it. + */ +public final class WebMvcEvidenceFilter extends OncePerRequestFilter implements Ordered { + + /** Where the tracker lives for the duration of the logical request. */ + public static final String EVIDENCE_ATTRIBUTE = + WebMvcEvidenceFilter.class.getName() + ".evidence"; + + private final int order; + + /** A filter at the platform's default position, just inside request-id assignment. */ + public WebMvcEvidenceFilter() { + this(Ordered.HIGHEST_PRECEDENCE + 20); + } + + /** + * A filter at an explicit position. + * + * @param order the filter order + */ + public WebMvcEvidenceFilter(int order) { + this.order = order; + } + + @Override + protected void doFilterInternal( + HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws IOException, ServletException { + WebExecutionEvidenceTracker tracker = existingTracker(request); + if (tracker == null) { + request.setAttribute(EVIDENCE_ATTRIBUTE, WebExecutionEvidenceTracker.received()); + } + chain.doFilter(request, response); + } + + /** + * Runs on async dispatches too. + * + *

The base class skips them by default. Skipping would mean the redispatch has no tracker, so + * whatever recorded evidence during the async completion would be writing to nothing. + */ + @Override + protected boolean shouldNotFilterAsyncDispatch() { + return false; + } + + /** + * Runs on error dispatches too. + * + *

An error dispatch is where a failed response is written, and the response axis is precisely + * what needs recording there. + */ + @Override + protected boolean shouldNotFilterErrorDispatch() { + return false; + } + + @Override + public int getOrder() { + return order; + } + + /** + * The tracker for a request, or null when this filter has not run. + * + * @param request the current request + */ + public static WebExecutionEvidenceTracker existingTracker(HttpServletRequest request) { + Object attribute = request.getAttribute(EVIDENCE_ATTRIBUTE); + return attribute instanceof WebExecutionEvidenceTracker tracker ? tracker : null; + } + + /** + * The tracker for a request. + * + * @throws IllegalStateException when the filter is not installed, rather than silently returning + * a fresh tracker whose evidence nothing else can see + */ + public static WebExecutionEvidenceTracker requireTracker(HttpServletRequest request) { + WebExecutionEvidenceTracker tracker = existingTracker(request); + if (tracker == null) { + throw new IllegalStateException( + "no execution evidence tracker on this request; WebMvcEvidenceFilter is not installed," + + " and a fresh tracker here would record evidence nothing else reads"); + } + return tracker; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/mvc/filter/WebMvcRequestIdFilter.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/mvc/filter/WebMvcRequestIdFilter.java new file mode 100644 index 00000000..13aff34a --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/mvc/filter/WebMvcRequestIdFilter.java @@ -0,0 +1,146 @@ +package dev.caskeleton.adapter.inbound.web.mvc.filter; + +import dev.caskeleton.adapter.inbound.web.core.WebRequestId; +import dev.caskeleton.adapter.inbound.web.core.WebTraceId; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.Optional; +import java.util.UUID; +import java.util.regex.Pattern; +import org.springframework.core.Ordered; +import org.springframework.web.filter.OncePerRequestFilter; + +/** + * Assigns the request and trace identifiers the rest of the request path is correlated by. + * + *

A client-supplied value is accepted only after it has been validated, and the validation is + * the point rather than a formality. These identifiers end up in log lines, metric exemplars and + * error bodies; an unvalidated header is therefore a log-injection primitive — a newline in it + * splits one log line into two, and the second one is written by the caller. + * + *

Trust is also configurable and defaults to off for the request id. A caller that can choose + * its own request id can make two different requests share one identity, which is how a support + * investigation ends up reading somebody else's exchange. + * + *

The trace id is different: propagating an inbound {@code traceparent} is the entire point of + * distributed tracing, so it is honoured when it parses, and replaced when it does not. + */ +public final class WebMvcRequestIdFilter extends OncePerRequestFilter implements Ordered { + + /** Where the assigned request id lives. */ + public static final String REQUEST_ID_ATTRIBUTE = + WebMvcRequestIdFilter.class.getName() + ".requestId"; + + /** Where the assigned trace id lives. */ + public static final String TRACE_ID_ATTRIBUTE = + WebMvcRequestIdFilter.class.getName() + ".traceId"; + + /** The header a client may propose a request id in. */ + public static final String REQUEST_ID_HEADER = "X-Request-Id"; + + /** The W3C trace context header. */ + public static final String TRACEPARENT_HEADER = "traceparent"; + + /** Printable, bounded, no separators a log parser would act on. */ + private static final Pattern SAFE_IDENTIFIER = Pattern.compile("[A-Za-z0-9._-]{1,128}"); + + /** {@code version-traceid-spanid-flags}; only the trace id is taken. */ + private static final Pattern TRACEPARENT = + Pattern.compile("[0-9a-f]{2}-([0-9a-f]{32})-[0-9a-f]{16}-[0-9a-f]{2}"); + + private final boolean trustInboundRequestId; + private final int order; + + /** A filter that mints its own request ids and propagates a valid inbound trace. */ + public WebMvcRequestIdFilter() { + this(false, Ordered.HIGHEST_PRECEDENCE + 10); + } + + /** + * A filter with an explicit trust decision. + * + * @param trustInboundRequestId whether a client may choose the request id, which only a trusted + * internal caller behind a sanitising proxy should be allowed to do + * @param order the filter order + */ + public WebMvcRequestIdFilter(boolean trustInboundRequestId, int order) { + this.trustInboundRequestId = trustInboundRequestId; + this.order = order; + } + + @Override + protected void doFilterInternal( + HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws IOException, ServletException { + if (request.getAttribute(REQUEST_ID_ATTRIBUTE) == null) { + WebRequestId requestId = resolveRequestId(request); + WebTraceId traceId = resolveTraceId(request); + request.setAttribute(REQUEST_ID_ATTRIBUTE, requestId); + request.setAttribute(TRACE_ID_ATTRIBUTE, traceId); + // Echoed so a client can quote it in a support ticket. Safe to publish: it names a log entry + // rather than a person, and it is this platform's value, not the caller's. + response.setHeader(REQUEST_ID_HEADER, requestId.value()); + } + chain.doFilter(request, response); + } + + @Override + protected boolean shouldNotFilterAsyncDispatch() { + return false; + } + + @Override + protected boolean shouldNotFilterErrorDispatch() { + return false; + } + + @Override + public int getOrder() { + return order; + } + + private WebRequestId resolveRequestId(HttpServletRequest request) { + if (!trustInboundRequestId) { + return new WebRequestId(UUID.randomUUID().toString()); + } + return sanitized(request.getHeader(REQUEST_ID_HEADER)) + .map(WebRequestId::new) + .orElseGet(() -> new WebRequestId(UUID.randomUUID().toString())); + } + + private WebTraceId resolveTraceId(HttpServletRequest request) { + String traceparent = request.getHeader(TRACEPARENT_HEADER); + if (traceparent != null) { + var matcher = TRACEPARENT.matcher(traceparent.trim()); + if (matcher.matches()) { + return new WebTraceId(matcher.group(1)); + } + } + // A malformed traceparent is replaced rather than rejected: refusing the request would let an + // upstream misconfiguration take an API down, and the platform still needs a correlation id. + return new WebTraceId(UUID.randomUUID().toString().replace("-", "")); + } + + private static Optional sanitized(String candidate) { + if (candidate == null) { + return Optional.empty(); + } + String trimmed = candidate.trim(); + return SAFE_IDENTIFIER.matcher(trimmed).matches() ? Optional.of(trimmed) : Optional.empty(); + } + + /** The request id assigned to a request, when this filter has run. */ + public static Optional requestId(HttpServletRequest request) { + Object value = request.getAttribute(REQUEST_ID_ATTRIBUTE); + return value instanceof WebRequestId id ? Optional.of(id) : Optional.empty(); + } + + /** The trace id assigned to a request, when this filter has run. */ + public static Optional traceId(HttpServletRequest request) { + Object value = request.getAttribute(TRACE_ID_ATTRIBUTE); + return value instanceof WebTraceId id ? Optional.of(id) : Optional.empty(); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/mvc/idempotency/IdempotentResponseWriter.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/mvc/idempotency/IdempotentResponseWriter.java new file mode 100644 index 00000000..7a0ba511 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/mvc/idempotency/IdempotentResponseWriter.java @@ -0,0 +1,84 @@ +package dev.caskeleton.adapter.inbound.web.mvc.idempotency; + +import dev.caskeleton.adapter.inbound.web.core.WebRequestContext; +import dev.caskeleton.adapter.inbound.web.error.WebProblem; +import dev.caskeleton.adapter.inbound.web.error.WebProblemFactory; +import dev.caskeleton.adapter.inbound.web.idempotency.IdempotencyResponsePlan; +import java.net.URI; +import java.util.List; +import java.util.Objects; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import tools.jackson.databind.json.JsonMapper; + +/** + * Turns an idempotency plan into an MVC response. + * + *

Separate from the invoker so that the replay path serializes through the same mapper as the + * fresh path. A replay that went out through a different mapper would differ from the response it + * claims to repeat in exactly the ways clients notice — number formatting, null omission, key order + * — while insisting it is the same answer. + */ +public final class IdempotentResponseWriter { + + private final JsonMapper mapper; + private final WebProblemFactory problems; + + /** + * A writer over one mapper. + * + * @param mapper the platform's strict mapper + * @param problems the problem document factory + */ + public IdempotentResponseWriter(JsonMapper mapper, WebProblemFactory problems) { + this.mapper = Objects.requireNonNull(mapper, "mapper"); + this.problems = Objects.requireNonNull(problems, "problems"); + } + + /** Serializes a handler result. */ + public String serialize(Object result) { + return mapper.writeValueAsString(result); + } + + /** + * Writes the answer for a plan that does not run the operation. + * + * @param plan what to answer + * @param context the request being answered + */ + public ResponseEntity write(IdempotencyResponsePlan plan, WebRequestContext context) { + Objects.requireNonNull(plan, "plan"); + Objects.requireNonNull(context, "context"); + if (plan.runOperation()) { + throw new IllegalArgumentException("this plan runs the operation; there is nothing to write"); + } + if (plan.replayed()) { + return ResponseEntity.status(plan.status()) + .contentType(MediaType.APPLICATION_JSON) + .header(IdempotencyResponsePlan.REPLAYED_HEADER, "true") + .body(plan.replayPayload().orElseThrow()); + } + WebProblem problem = + problems.create( + plan.problemCode().orElseThrow(), + detailFor(plan), + URI.create("/requests/" + context.requestId().value()), + context.traceId().value(), + List.of()); + problems.requireStatusAgreement(plan.status(), problem); + ResponseEntity.BodyBuilder builder = + ResponseEntity.status(plan.status()).contentType(MediaType.valueOf(WebProblem.MEDIA_TYPE)); + if (plan.status() == 409) { + builder.header( + IdempotencyResponsePlan.RETRY_AFTER_HEADER, + Integer.toString(IdempotencyResponsePlan.IN_PROGRESS_RETRY_AFTER_SECONDS)); + } + return builder.body(mapper.writeValueAsString(problem)); + } + + private static String detailFor(IdempotencyResponsePlan plan) { + return plan.status() == 409 + ? "an earlier attempt with this idempotency key is still running" + : "this idempotency key was already used for a different request"; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/mvc/idempotency/WebMvcIdempotentInvoker.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/mvc/idempotency/WebMvcIdempotentInvoker.java new file mode 100644 index 00000000..aceb3928 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/mvc/idempotency/WebMvcIdempotentInvoker.java @@ -0,0 +1,108 @@ +package dev.caskeleton.adapter.inbound.web.mvc.idempotency; + +import dev.caskeleton.adapter.inbound.web.core.WebRequestContext; +import dev.caskeleton.adapter.inbound.web.http.ApiHeaders; +import dev.caskeleton.adapter.inbound.web.idempotency.IdempotencyAdmission; +import dev.caskeleton.adapter.inbound.web.idempotency.IdempotencyResponsePlan; +import dev.caskeleton.adapter.inbound.web.idempotency.WebIdempotencyGate; +import dev.caskeleton.adapter.inbound.web.operation.WebOperationProfile; +import jakarta.servlet.http.HttpServletRequest; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.function.Supplier; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; + +/** + * Runs a mutating MVC handler under an idempotency key. + * + *

Invoked from the handler with the already-bound command rather than installed as a filter, + * because the fingerprint this platform stores is semantic: it is computed from the bound model, so + * that a client library reordering JSON members or a proxy reformatting whitespace does not turn a + * retry into a new request. A filter runs before binding and can only see bytes. + * + *

The invoker never releases a claim it is not certain is safe to release. A handler that threw + * after the application committed leaves the claim in place, so the client's retry sees 409 and + * asks again rather than performing the write twice. + */ +public final class WebMvcIdempotentInvoker { + + private final WebIdempotencyGate gate; + private final IdempotentResponseWriter writer; + + /** + * An invoker over one gate. + * + * @param gate the transport-neutral admission decision + * @param writer turns a plan into a response entity + */ + public WebMvcIdempotentInvoker(WebIdempotencyGate gate, IdempotentResponseWriter writer) { + this.gate = Objects.requireNonNull(gate, "gate"); + this.writer = Objects.requireNonNull(writer, "writer"); + } + + /** + * Invokes the handler once per key. + * + * @param request the servlet request, read for headers only + * @param context the resolved request context + * @param profile the operation's declared profile + * @param pathVariables the identifiers the path named + * @param command the bound request model + * @param handler the operation itself + * @param successStatus the status a fresh success is answered with + */ + public ResponseEntity invoke( + HttpServletRequest request, + WebRequestContext context, + WebOperationProfile profile, + Map pathVariables, + Object command, + Supplier handler, + int successStatus) { + Objects.requireNonNull(request, "request"); + Objects.requireNonNull(context, "context"); + Objects.requireNonNull(profile, "profile"); + Objects.requireNonNull(handler, "handler"); + + String rawKey = request.getHeader(ApiHeaders.IDEMPOTENCY_KEY); + String principal = context.actor().subject(); + String tenantId = context.tenant().value().orElse(null); + + IdempotencyAdmission admission = + gate.admit( + profile.idempotency(), + profile.operationName(), + principal, + tenantId, + rawKey, + pathVariables, + command, + headersOf(request)); + IdempotencyResponsePlan plan = IdempotencyResponsePlan.of(admission, successStatus); + if (!plan.runOperation()) { + return writer.write(plan, context); + } + + Object result = handler.get(); + String payload = writer.serialize(result); + if (admission.outcome() == IdempotencyAdmission.Outcome.PROCEED) { + // After the handler returned, so a record only ever exists for work that actually finished. + // Storing before would let a crash mid-handler leave a COMPLETED record for a write that + // never happened, and every retry would then be answered with a receipt for nothing. + gate.complete(profile.operationName(), principal, tenantId, rawKey, payload); + } + return ResponseEntity.status(successStatus) + .contentType(MediaType.APPLICATION_JSON) + .body(payload); + } + + private static Map headersOf(HttpServletRequest request) { + Map headers = new LinkedHashMap<>(); + Collections.list(request.getHeaderNames()) + .forEach(name -> headers.put(name, request.getHeader(name))); + return Map.copyOf(headers); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/mvc/operation/OperationHttpController.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/mvc/operation/OperationHttpController.java new file mode 100644 index 00000000..0f5c4dfd --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/mvc/operation/OperationHttpController.java @@ -0,0 +1,105 @@ +package dev.caskeleton.adapter.inbound.web.mvc.operation; + +import dev.caskeleton.adapter.inbound.web.core.WebRequestContext; +import dev.caskeleton.adapter.inbound.web.error.WebResourceNotVisibleException; +import dev.caskeleton.adapter.inbound.web.operationasync.OperationId; +import dev.caskeleton.adapter.inbound.web.operationasync.OperationQueryService; +import dev.caskeleton.adapter.inbound.web.operationasync.OperationResource; +import dev.caskeleton.adapter.inbound.web.operationasync.OperationResponse; +import dev.caskeleton.adapter.inbound.web.operationasync.OperationResponseMapper; +import dev.caskeleton.adapter.inbound.web.operationasync.OperationStatus; +import java.net.URI; +import java.util.Objects; +import java.util.Optional; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * The polled operation resource, on the servlet stack. + * + *

The route is the platform's, not an application's: the whole point of a durable operation is + * that one shape of receipt works for every long-running endpoint. An application that published + * its own polling route per operation would make every client learn a new one. + */ +@RestController +// Gated, because the durable operation store only exists when a persistence adapter provides one. +// Without the gate this controller is component-scanned into every deployment and the context +// fails to start with an unsatisfied dependency — which is exactly what happened, and what the +// all-adapters-off startup contract caught. +@ConditionalOnProperty( + prefix = "app.web-platform.durable-operations", + name = "enabled", + havingValue = "true") +@RequestMapping(OperationHttpController.BASE_PATH) +public class OperationHttpController { + + /** Where operations are published. */ + public static final String BASE_PATH = "/api/v1/operations"; + + private final OperationQueryService operations; + + /** + * A controller over the query service. + * + * @param operations reads and cancels operations + */ + public OperationHttpController(OperationQueryService operations) { + this.operations = Objects.requireNonNull(operations, "operations"); + } + + /** The URI a 202 receipt points at. */ + public static URI locationOf(String operationId) { + return URI.create(BASE_PATH + "/" + new OperationId(operationId).value()); + } + + /** Reads one operation. */ + @GetMapping(path = "/{operationId}", produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity get( + @PathVariable String operationId, WebRequestContext context) { + Optional resource = operations.find(new OperationId(operationId), context); + if (resource.isEmpty()) { + // Thrown, not returned. `notFound().build()` is a status with no body, which is a failure a + // client cannot branch on; the exception reaches the platform's problem handler and comes + // out as a document like every other failure. + // + // 404 for both "absent" and "not yours": distinguishing them turns a guessable id into an + // oracle for what other callers are running. + throw new WebResourceNotVisibleException("operation"); + } + OperationResource operation = resource.get(); + ResponseEntity.BodyBuilder builder = ResponseEntity.ok(); + operation + .retryAfter() + .ifPresent(after -> builder.header("Retry-After", Long.toString(after.toSeconds()))); + if (operation.status() == OperationStatus.SUCCEEDED) { + operation + .resultLocation() + .ifPresent(location -> builder.header("Content-Location", location.toString())); + } + return builder.body(OperationResponseMapper.from(operation)); + } + + /** + * Requests cancellation. + * + *

202 rather than 204: cancellation is a request, and a worker mid-flight may still finish. + * Answering 204 would tell the client the work has stopped when only the intent is recorded. + */ + @DeleteMapping("/{operationId}") + public ResponseEntity cancel(@PathVariable String operationId, WebRequestContext context) { + Optional outcome = operations.cancel(new OperationId(operationId), context); + if (outcome.isEmpty()) { + throw new WebResourceNotVisibleException("operation"); + } + // A second cancel, and a cancel of work that already finished, both land here. 409 would + // invite a retry that can never succeed; 202 says the same true thing every time — the + // request was received and the operation's state is what it is. + return ResponseEntity.accepted().header("Location", BASE_PATH + "/" + operationId).build(); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/mvc/throttle/WebMvcThrottleFilter.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/mvc/throttle/WebMvcThrottleFilter.java new file mode 100644 index 00000000..132e294e --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/mvc/throttle/WebMvcThrottleFilter.java @@ -0,0 +1,136 @@ +package dev.caskeleton.adapter.inbound.web.mvc.throttle; + +import dev.caskeleton.adapter.inbound.web.admission.AdmissionDecision; +import dev.caskeleton.adapter.inbound.web.admission.AdmissionPermit; +import dev.caskeleton.adapter.inbound.web.admission.WebAdmissionController; +import dev.caskeleton.adapter.inbound.web.core.WebRequestContext; +import dev.caskeleton.adapter.inbound.web.error.ThrottleProblemWriter; +import dev.caskeleton.adapter.inbound.web.error.WebProblem; +import dev.caskeleton.adapter.inbound.web.operation.WebOperationProfile; +import dev.caskeleton.adapter.inbound.web.ratelimit.RateLimitDecision; +import dev.caskeleton.adapter.inbound.web.ratelimit.RateLimitFailurePolicy; +import dev.caskeleton.adapter.inbound.web.ratelimit.RateLimitProfileName; +import dev.caskeleton.adapter.inbound.web.ratelimit.WebRateLimiter; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.Objects; +import java.util.function.Function; +import org.springframework.web.filter.OncePerRequestFilter; +import tools.jackson.databind.json.JsonMapper; + +/** + * Applies the caller's quota and then the service's capacity, in that order. + * + *

Quota first, deliberately. Charging a caller's quota is cheap and refusing on it costs the + * service nothing; taking an admission slot occupies capacity that a caller who was about to be + * rate-limited anyway should never have held. The reverse order lets an abusive caller consume + * slots on its way to being told 429. + * + *

One filter for both, because the release of the admission permit has to wrap the rest of the + * chain. Split across two filters, the permit's {@code finally} would live in the outer one and the + * acquisition in the inner, which is the shape that leaks capacity the first time somebody reorders + * them. + */ +public final class WebMvcThrottleFilter extends OncePerRequestFilter { + + private final WebRateLimiter limiter; + private final WebAdmissionController admission; + private final Function profiles; + private final Function contexts; + private final RateLimitFailurePolicy failurePolicy; + private final ThrottleProblemWriter problems; + private final JsonMapper mapper; + + /** + * A filter over one limiter and one admission controller. + * + * @param limiter the caller quota + * @param admission the service capacity + * @param profiles resolves the operation profile for a request + * @param contexts resolves the request context + * @param failurePolicy what to do when the limiter's store is unreachable + * @param problems renders the refusals + * @param mapper serializes them + */ + public WebMvcThrottleFilter( + WebRateLimiter limiter, + WebAdmissionController admission, + Function profiles, + Function contexts, + RateLimitFailurePolicy failurePolicy, + ThrottleProblemWriter problems, + JsonMapper mapper) { + this.limiter = Objects.requireNonNull(limiter, "limiter"); + this.admission = Objects.requireNonNull(admission, "admission"); + this.profiles = Objects.requireNonNull(profiles, "profiles"); + this.contexts = Objects.requireNonNull(contexts, "contexts"); + this.failurePolicy = Objects.requireNonNull(failurePolicy, "failurePolicy"); + this.problems = Objects.requireNonNull(problems, "problems"); + this.mapper = Objects.requireNonNull(mapper, "mapper"); + } + + @Override + protected void doFilterInternal( + HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws ServletException, IOException { + WebOperationProfile profile = profiles.apply(request); + WebRequestContext context = contexts.apply(request); + + RateLimitDecision quota = evaluateQuota(context); + if (!quota.allowed()) { + write( + response, + problems.quotaStatus(), + problems.quotaExhausted(URI.create(request.getRequestURI()), context.traceId().value()), + ThrottleProblemWriter.retryAfterSeconds(quota.retryAfter().orElseThrow())); + return; + } + + AdmissionDecision admitted = admission.admit(profile.admission()); + if (!admitted.admitted()) { + write( + response, + problems.capacityStatus(), + problems.capacityExhausted( + URI.create(request.getRequestURI()), context.traceId().value()), + ThrottleProblemWriter.retryAfterSeconds(admitted.retryAfter().orElseThrow())); + return; + } + + // try-with-resources rather than a finally block: the permit is released on every exit, + // including the ones a later edit adds. + try (AdmissionPermit permit = admitted.permit().orElseThrow()) { + chain.doFilter(request, response); + } + } + + private RateLimitDecision evaluateQuota(WebRequestContext context) { + try { + return limiter.evaluate(context, RateLimitProfileName.standard()); + } catch (RuntimeException limiterUnavailable) { + if (failurePolicy == RateLimitFailurePolicy.FAIL_CLOSED) { + return RateLimitDecision.refused( + 1, context.receivedAt().plusSeconds(1), java.time.Duration.ofSeconds(1)); + } + // Fail open: the limiter's store is down and this operation's profile says serving is the + // lesser harm. The decision is the profile's, made at review time, not this filter's. + return RateLimitDecision.allowed(1, 1, context.receivedAt().plusSeconds(1)); + } + } + + private void write( + HttpServletResponse response, int status, WebProblem problem, String retryAfter) + throws IOException { + response.reset(); + response.setStatus(status); + response.setContentType(WebProblem.MEDIA_TYPE); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + response.setHeader("Retry-After", retryAfter); + response.getWriter().write(mapper.writeValueAsString(problem)); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/observability/WebAccessLogEvent.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/observability/WebAccessLogEvent.java new file mode 100644 index 00000000..7a8ab4e5 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/observability/WebAccessLogEvent.java @@ -0,0 +1,65 @@ +package dev.caskeleton.adapter.inbound.web.observability; + +import java.time.Duration; +import java.time.Instant; +import java.util.Objects; + +/** + * One line of the access log, structured. + * + *

A record rather than a format string, so what is logged is decided once and by a type. The + * classic access-log failure is that the format grows a field somebody needed for one investigation + * — a query string, a header, a body excerpt — and it is then written for every request forever, + * into a store with a different retention policy and a wider audience than anybody considered. + * + *

What is deliberately absent: the query string, request and response bodies, headers, and the + * raw tenant identifier. Each is available at the point of logging and each is a leak. The trace id + * is here so that an investigation can reach the detail through a system that was designed to hold + * it. + * + * @param requestId this request's identifier + * @param traceId the correlation identifier + * @param method the HTTP method + * @param routeTemplate the matched template, never the resolved path + * @param status the response status + * @param problemCode the failure code when there was one, else null + * @param operationName the operation invoked + * @param apiVersion the API major version served + * @param clientProfile a bounded label for the kind of client + * @param duration how long it took + * @param requestBytes bytes read from the request + * @param responseBytes bytes written to the response + * @param at when the request completed + */ +public record WebAccessLogEvent( + String requestId, + String traceId, + String method, + String routeTemplate, + int status, + String problemCode, + String operationName, + int apiVersion, + String clientProfile, + Duration duration, + long requestBytes, + long responseBytes, + Instant at) { + + public WebAccessLogEvent { + Objects.requireNonNull(requestId, "requestId"); + Objects.requireNonNull(traceId, "traceId"); + Objects.requireNonNull(method, "method"); + Objects.requireNonNull(routeTemplate, "routeTemplate"); + Objects.requireNonNull(operationName, "operationName"); + Objects.requireNonNull(duration, "duration"); + Objects.requireNonNull(at, "at"); + if (routeTemplate.contains("?")) { + // A query string in the route field is the most common way personal data reaches an access + // log: search terms, email addresses, tokens that a client put in a query parameter. + throw new IllegalArgumentException( + "the access log records a route template, not a URL; a query string here is how search" + + " terms and tokens end up in a log with a different retention policy"); + } + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/observability/WebAccessLogger.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/observability/WebAccessLogger.java new file mode 100644 index 00000000..fd84d9ed --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/observability/WebAccessLogger.java @@ -0,0 +1,19 @@ +package dev.caskeleton.adapter.inbound.web.observability; + +/** + * Where one completed request is recorded. + * + *

A port so the destination is a deployment decision. What is not negotiable is the shape: the + * event is a {@link WebAccessLogEvent}, so no implementation can add a field the type does not + * have. + * + *

Called exactly once per logical request. On the servlet stack that means checking the + * dispatcher type, because an async request passes through the filter chain twice and a second line + * makes every percentile computed from this data wrong. + */ +@FunctionalInterface +public interface WebAccessLogger { + + /** Records one completed request. */ + void record(WebAccessLogEvent event); +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/observability/WebAuditAction.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/observability/WebAuditAction.java new file mode 100644 index 00000000..9a305646 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/observability/WebAuditAction.java @@ -0,0 +1,41 @@ +package dev.caskeleton.adapter.inbound.web.observability; + +/** + * The operations that are audited rather than merely logged. + * + *

A closed set, and short on purpose. These are the actions where the question later is not + * "what did the system do" but "who decided this" — an operator reaching past a safety rail. They + * are separated from the access log because they need different retention, a different audience, + * and a guarantee that a log-level change cannot switch them off. + * + *

Everything here has the same shape: a human overrode a control the platform put in place. That + * is the membership test, not severity. + */ +public enum WebAuditAction { + + /** A resource removed past whatever protection normally prevents it. */ + ADMIN_FORCE_DELETE, + + /** Failed work pushed back onto a queue by hand. */ + ADMIN_REDRIVE, + + /** A grant or a role changed. */ + PERMISSION_CHANGE, + + /** + * An API's retirement date moved. + * + *

Audited because it is a promise to third parties. Moving a sunset date earlier breaks + * integrations that planned around it, and the record of who moved it is what makes that + * answerable. + */ + SUNSET_CHANGE, + + /** + * An idempotency record cleared or bypassed. + * + *

The one whose consequence is least obvious: clearing a record makes a retry execute again, + * so an operator doing this is authorising a possible duplicate write. + */ + IDEMPOTENCY_OVERRIDE +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/observability/WebAuditEvent.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/observability/WebAuditEvent.java new file mode 100644 index 00000000..7118a824 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/observability/WebAuditEvent.java @@ -0,0 +1,55 @@ +package dev.caskeleton.adapter.inbound.web.observability; + +import java.time.Instant; +import java.util.Map; +import java.util.Objects; + +/** + * A record of somebody overriding a control. + * + *

Unlike an access-log line, this one names the actor and the resource. That is the whole point: + * the question an audit answers is who did this to what, and an anonymised audit record answers + * nothing. The constructor therefore requires both, rather than letting an event be written without + * them and discovered to be useless during an investigation. + * + *

The reason is required for the same purpose. An override with no stated reason is a decision + * nobody can evaluate afterwards, and "it was needed at the time" is what every one of them looks + * like a year later. + * + * @param action what was overridden + * @param actor who did it + * @param resource what it was done to + * @param reason why, as given at the time + * @param traceId the request that carried it + * @param at when + * @param detail additional bounded facts about the override + */ +public record WebAuditEvent( + WebAuditAction action, + String actor, + String resource, + String reason, + String traceId, + Instant at, + Map detail) { + + public WebAuditEvent { + Objects.requireNonNull(action, "action"); + Objects.requireNonNull(traceId, "traceId"); + Objects.requireNonNull(at, "at"); + Objects.requireNonNull(detail, "detail"); + if (actor == null || actor.isBlank()) { + throw new IllegalArgumentException( + "an audit event without an actor answers nothing; the question it exists for is who did" + + " this"); + } + if (resource == null || resource.isBlank()) { + throw new IllegalArgumentException("an audit event must name what was acted on"); + } + if (reason == null || reason.isBlank()) { + throw new IllegalArgumentException( + "an override with no stated reason cannot be evaluated later, only inherited"); + } + detail = Map.copyOf(detail); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/observability/WebAuditPublisher.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/observability/WebAuditPublisher.java new file mode 100644 index 00000000..43cca5eb --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/observability/WebAuditPublisher.java @@ -0,0 +1,17 @@ +package dev.caskeleton.adapter.inbound.web.observability; + +/** + * Where audit events go. + * + *

A separate port from {@link WebAccessLogger}, and separate on purpose rather than as layering. + * An audit trail needs a retention period measured in years, a restricted audience, and an + * append-only destination; an access log needs none of those and is routinely sampled, rotated and + * shipped to whatever the observability vendor is this year. One port for both would mean one of + * those two sets of requirements silently applies to the other. + */ +@FunctionalInterface +public interface WebAuditPublisher { + + /** Records one override. */ + void publish(WebAuditEvent event); +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/observability/WebMetricCardinalityPolicy.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/observability/WebMetricCardinalityPolicy.java new file mode 100644 index 00000000..3c8f5520 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/observability/WebMetricCardinalityPolicy.java @@ -0,0 +1,112 @@ +package dev.caskeleton.adapter.inbound.web.observability; + +import java.util.Locale; +import java.util.Objects; +import java.util.Set; + +/** + * Which tags a metric may carry. + * + *

An allowlist, not a denylist. A metric's cost is the product of its tags' cardinalities, so a + * single tag with one value per user turns one time series into millions — and the failure is not + * an error anywhere. It is a monitoring bill, then a slow dashboard, then a metrics backend that + * starts dropping series, at which point the alert that was supposed to fire does not. + * + *

A denylist would have to anticipate every high-cardinality name anyone will ever invent. + * {@code userId} is obvious; {@code sessionRef}, {@code correlation}, {@code batchId} are not, and + * each one only has to be forgotten once. + * + *

Secrecy rides along for free. A tag value reaches the metrics backend, the dashboards and + * usually a third-party SaaS, and it is not redacted on the way — so an idempotency key or a token + * in a tag is an unencrypted export of it. + */ +public final class WebMetricCardinalityPolicy { + + /** + * The complete set of tags a metric may carry. + * + *

Every one is bounded by something the deployment controls: the routing table, the HTTP spec, + * the problem catalog, the operation registry. None grows with traffic. + */ + private static final Set ALLOWED = + Set.of( + // Bounded by the routing table. The *template*, never the resolved path: "/orders/{id}" + // is one series and "/orders/8a1f…" is one per order. + "routeTemplate", + "http.method", + "http.status", + "outcome", + "apiVersion", + "operationName", + "problemCode", + "clientProfile"); + + private WebMetricCardinalityPolicy() {} + + /** The standard policy. */ + public static WebMetricCardinalityPolicy standard() { + return new WebMetricCardinalityPolicy(); + } + + /** Whether a tag may be recorded. */ + public boolean allowed(String name) { + return name != null && ALLOWED.contains(name); + } + + /** The complete allowlist. */ + public Set allowedTags() { + return ALLOWED; + } + + /** + * Refuses a tag that is not on the allowlist. + * + * @param name the tag being recorded + * @throws IllegalArgumentException when it is not allowed + */ + public void require(String name) { + if (!allowed(name)) { + throw new IllegalArgumentException( + "metric tag '" + + name + + "' is not on the allowlist " + + ALLOWED + + "; an unbounded tag multiplies every series that carries it, and the failure is a" + + " metrics backend dropping data rather than an error anyone sees"); + } + } + + /** + * Whether a name looks like something that must never be a tag. + * + *

Advisory, for diagnostics and for the message a reviewer reads. The allowlist is what + * actually enforces the rule; this only explains why a particular name was refused. + */ + public static boolean obviouslyUnbounded(String name) { + if (name == null) { + return false; + } + String lower = name.toLowerCase(Locale.ROOT); + return lower.contains("id") + || lower.contains("url") + || lower.contains("uri") + || lower.contains("path") + || lower.contains("query") + || lower.contains("key") + || lower.contains("token") + || lower.contains("cookie") + || lower.contains("body") + || lower.contains("tenant") + || lower.contains("user"); + } + + @Override + public boolean equals(Object other) { + return other instanceof WebMetricCardinalityPolicy; + } + + @Override + public int hashCode() { + return Objects.hash(ALLOWED); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/observability/WebObservationConvention.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/observability/WebObservationConvention.java new file mode 100644 index 00000000..de2b95f4 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/observability/WebObservationConvention.java @@ -0,0 +1,72 @@ +package dev.caskeleton.adapter.inbound.web.observability; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + +/** + * Builds the tag set for one observation, refusing anything off the allowlist. + * + *

A builder rather than a map the caller assembles, because the allowlist has to be applied at + * the point of construction. Checked afterwards, a forbidden tag has already been written by + * whoever built the map — and reviewing every call site is the process this class exists to + * replace. + */ +public final class WebObservationConvention { + + private final WebMetricCardinalityPolicy policy; + private final Map tags = new LinkedHashMap<>(); + + /** + * A convention over one policy. + * + * @param policy which tags are allowed + */ + public WebObservationConvention(WebMetricCardinalityPolicy policy) { + this.policy = Objects.requireNonNull(policy, "policy"); + } + + /** A convention over the standard policy. */ + public static WebObservationConvention standard() { + return new WebObservationConvention(WebMetricCardinalityPolicy.standard()); + } + + /** + * Adds a tag. + * + * @throws IllegalArgumentException when the tag is not on the allowlist + */ + public WebObservationConvention tag(String name, String value) { + policy.require(name); + Objects.requireNonNull(value, "value"); + tags.put(name, value); + return this; + } + + /** + * The route template, never the resolved path. + * + *

Separate from {@link #tag} because this is the one that gets it wrong in practice: the + * resolved path is what is easiest to reach for and it produces one series per resource. + * + * @param template a path with its variables still as placeholders + */ + public WebObservationConvention routeTemplate(String template) { + Objects.requireNonNull(template, "template"); + if (!template.contains("{") && template.chars().anyMatch(Character::isDigit)) { + // Not proof, but the cheapest signal that a resolved path slipped through: templates carry + // braces, and a digit-bearing segment with none is almost always an identifier. + throw new IllegalArgumentException( + "routeTemplate '" + + template + + "' looks like a resolved path rather than a template; one series per resource is" + + " how a metrics backend starts dropping data"); + } + return tag("routeTemplate", template); + } + + /** The tags collected so far. */ + public Map tags() { + return Map.copyOf(tags); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/openapi/CursorSchemaContributor.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/openapi/CursorSchemaContributor.java new file mode 100644 index 00000000..1be7552f --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/openapi/CursorSchemaContributor.java @@ -0,0 +1,47 @@ +package dev.caskeleton.adapter.inbound.web.openapi; + +import io.swagger.v3.oas.models.media.ArraySchema; +import io.swagger.v3.oas.models.media.BooleanSchema; +import io.swagger.v3.oas.models.media.ObjectSchema; +import io.swagger.v3.oas.models.media.Schema; +import io.swagger.v3.oas.models.media.StringSchema; +import java.util.List; + +/** + * Renders the cursor page envelope into the published document. + * + *

The cursor is a string with no declared structure, and that is the contract rather than an + * omission. Publishing its shape would invite a client to construct one, and a constructed cursor + * skips the signature that makes it tamper-evident — at which point a caller can page into rows the + * query was scoped away from. + */ +public final class CursorSchemaContributor { + + private CursorSchemaContributor() {} + + /** The schema name the document publishes. */ + public static final String SCHEMA_NAME = "CursorPage"; + + /** + * The page envelope. + * + *

This is the one wrapper the platform publishes, and it is not the global response envelope + * the design forbids: it carries pagination state that has nowhere else to live, applies only to + * collection responses, and adds no second status. + */ + public static Schema schema() { + ObjectSchema page = new ObjectSchema(); + page.description("A page of results with an opaque continuation cursor."); + page.addProperty("items", new ArraySchema().items(new ObjectSchema())); + page.addProperty( + "nextCursor", + new StringSchema() + .description( + "Opaque and signed. Its structure is deliberately unpublished: a client that" + + " constructs one skips the signature that keeps it tamper-evident.")); + page.addProperty("hasMore", new BooleanSchema()); + page.required(List.of("items", "hasMore")); + page.additionalProperties(false); + return page; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/openapi/ProblemSchemaContributor.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/openapi/ProblemSchemaContributor.java new file mode 100644 index 00000000..4be10e76 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/openapi/ProblemSchemaContributor.java @@ -0,0 +1,88 @@ +package dev.caskeleton.adapter.inbound.web.openapi; + +import dev.caskeleton.adapter.inbound.web.error.ProblemCatalog; +import dev.caskeleton.adapter.inbound.web.error.ProblemCode; +import io.swagger.v3.oas.models.media.ArraySchema; +import io.swagger.v3.oas.models.media.ObjectSchema; +import io.swagger.v3.oas.models.media.Schema; +import io.swagger.v3.oas.models.media.StringSchema; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; + +/** + * Renders the RFC 9457 problem body into the published document. + * + *

The {@code code} enum is generated from {@link ProblemCode} rather than written out. A + * hand-maintained list drifts the moment a code is added, and it drifts in the direction that + * matters: a client generator produces a sealed enum from the document, so a code the document + * omits becomes a deserialisation failure in every generated client the first time the service + * returns it. + */ +public final class ProblemSchemaContributor { + + private ProblemSchemaContributor() {} + + /** The schema name the document publishes. */ + public static final String SCHEMA_NAME = "Problem"; + + /** The validation issue schema name. */ + public static final String ISSUE_SCHEMA_NAME = "ValidationIssue"; + + /** The problem body, with its member set closed to what the platform actually sends. */ + public static Schema schema() { + ObjectSchema problem = new ObjectSchema(); + problem.description( + "RFC 9457 problem details. The member set is closed: the platform sends no extension" + + " beyond traceId and errors."); + problem.addProperty("type", new StringSchema().format("uri")); + problem.addProperty("title", new StringSchema()); + problem.addProperty( + "status", + new io.swagger.v3.oas.models.media.IntegerSchema() + .minimum(java.math.BigDecimal.valueOf(400)) + .maximum(java.math.BigDecimal.valueOf(599))); + problem.addProperty("detail", new StringSchema()); + problem.addProperty("instance", new StringSchema().format("uri")); + problem.addProperty("code", codeSchema()); + problem.addProperty("traceId", new StringSchema()); + problem.addProperty( + "errors", + new ArraySchema().items(new Schema<>().$ref("#/components/schemas/" + ISSUE_SCHEMA_NAME))); + problem.required(List.of("type", "title", "status", "code", "traceId")); + problem.additionalProperties(false); + return problem; + } + + /** The field-level issue schema, with no member for the submitted value. */ + public static Schema issueSchema() { + ObjectSchema issue = new ObjectSchema(); + issue.description( + "One field-level reason. There is deliberately no rejectedValue member: echoing submitted" + + " content back is how a password typed into the wrong field reaches a log."); + issue.addProperty("pointer", new StringSchema().description("RFC 6901 JSON Pointer")); + issue.addProperty("code", new StringSchema()); + issue.addProperty("message", new StringSchema()); + issue.required(List.of("pointer", "code", "message")); + issue.additionalProperties(false); + return issue; + } + + /** Every published problem code, generated from the enum. */ + public static Schema codeSchema() { + StringSchema code = new StringSchema(); + Arrays.stream(ProblemCode.values()).map(Enum::name).sorted().forEach(code::addEnumItemObject); + return code; + } + + /** + * Confirms the document's code list is the runtime catalog's. + * + * @param catalog the runtime catalog + * @return the codes the document would publish + */ + public static List publishedCodes(ProblemCatalog catalog) { + Objects.requireNonNull(catalog, "catalog"); + return catalog.definitions().keySet().stream().map(Enum::name).sorted().toList(); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/openapi/WebOpenApiBreakingPolicy.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/openapi/WebOpenApiBreakingPolicy.java new file mode 100644 index 00000000..c4357624 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/openapi/WebOpenApiBreakingPolicy.java @@ -0,0 +1,187 @@ +package dev.caskeleton.adapter.inbound.web.openapi; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.Operation; +import io.swagger.v3.oas.models.PathItem; +import io.swagger.v3.oas.models.media.Schema; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.TreeSet; + +/** + * Decides which document changes break a client. + * + *

The asymmetry is the whole rule and it is easy to get backwards. Removing a response + * field breaks a client that reads it. Adding a required request field breaks a client + * that does not send it. So a removal is breaking on the way out and an addition is breaking on the + * way in, and a diff that treats "field added" or "field removed" as one category will wave one of + * them through. + * + *

Removing a route or an operation is breaking; adding one is not. Narrowing an enum is breaking + * — a client may already send the value being dropped — while widening a response enum is + * also breaking, because a generated client deserialises into a sealed type and fails on a value + * the document never mentioned. + */ +public final class WebOpenApiBreakingPolicy { + + /** + * Compares a released document against the current one. + * + * @param released the approved snapshot + * @param current the document this build produces + */ + public WebOpenApiDiffResult diff(OpenAPI released, OpenAPI current) { + Objects.requireNonNull(released, "released"); + Objects.requireNonNull(current, "current"); + List breaking = new ArrayList<>(); + List additive = new ArrayList<>(); + + Set releasedOperations = operationKeys(released); + Set currentOperations = operationKeys(current); + for (String operation : releasedOperations) { + if (!currentOperations.contains(operation)) { + breaking.add("operation removed: " + operation); + } + } + for (String operation : currentOperations) { + if (!releasedOperations.contains(operation)) { + additive.add("operation added: " + operation); + } + } + + Map> releasedSchemas = schemas(released); + Map> currentSchemas = schemas(current); + for (var entry : releasedSchemas.entrySet()) { + Schema before = entry.getValue(); + Schema after = currentSchemas.get(entry.getKey()); + if (after == null) { + breaking.add("schema removed: " + entry.getKey()); + continue; + } + compareSchema(entry.getKey(), before, after, breaking, additive); + } + for (String name : currentSchemas.keySet()) { + if (!releasedSchemas.containsKey(name)) { + additive.add("schema added: " + name); + } + } + return new WebOpenApiDiffResult(breaking, additive); + } + + private void compareSchema( + String name, + Schema before, + Schema after, + List breaking, + List additive) { + Set beforeProperties = propertyNames(before); + Set afterProperties = propertyNames(after); + for (String property : beforeProperties) { + if (!afterProperties.contains(property)) { + // A removed member breaks whoever reads it. Whether the schema is used in a request or a + // response is not knowable from the component alone, so this is reported as breaking: the + // conservative direction, because the alternative is waving through a removal that breaks + // every client that read the field. + breaking.add("property removed: " + name + "." + property); + } + } + for (String property : afterProperties) { + if (!beforeProperties.contains(property)) { + additive.add("property added: " + name + "." + property); + } + } + Set beforeRequired = required(before); + Set afterRequired = required(after); + for (String property : afterRequired) { + if (!beforeRequired.contains(property)) { + breaking.add("property became required: " + name + "." + property); + } + } + Set beforeEnum = enumValues(before); + Set afterEnum = enumValues(after); + if (!beforeEnum.isEmpty() || !afterEnum.isEmpty()) { + for (String value : beforeEnum) { + if (!afterEnum.contains(value)) { + breaking.add("enum value removed: " + name + "." + value); + } + } + for (String value : afterEnum) { + if (!beforeEnum.contains(value)) { + // A generated client deserialises a response enum into a sealed type, so a value the + // released document never mentioned is a failure rather than an unknown. + breaking.add("enum value added: " + name + "." + value); + } + } + } + } + + private static Set operationKeys(OpenAPI api) { + Set keys = new TreeSet<>(); + if (api.getPaths() == null) { + return keys; + } + api.getPaths() + .forEach( + (path, item) -> { + for (var entry : methodsOf(item).entrySet()) { + if (entry.getValue() != null) { + keys.add(entry.getKey() + " " + path); + } + } + }); + return keys; + } + + private static Map methodsOf(PathItem item) { + Map methods = new java.util.LinkedHashMap<>(); + methods.put("GET", item.getGet()); + methods.put("PUT", item.getPut()); + methods.put("POST", item.getPost()); + methods.put("DELETE", item.getDelete()); + methods.put("PATCH", item.getPatch()); + methods.put("HEAD", item.getHead()); + methods.put("OPTIONS", item.getOptions()); + return methods; + } + + /** + * The component schemas, widened out of io.swagger's raw-valued map. + * + *

The widening happens here rather than at the call sites so the raw type appears exactly + * once. It was on both callers before, which left two locals declared as a raw {@code Schema} — + * invisible to javac, which only lints raw types it is told to, and reported by the Eclipse + * compiler the editor runs. Every value in the map is a {@code Schema} of some type argument, so + * widening to {@code Schema} is sound; Java has no cast that says so without passing through + * the raw type. + */ + @SuppressWarnings({"rawtypes", "unchecked"}) + private static Map> schemas(OpenAPI api) { + if (api.getComponents() == null || api.getComponents().getSchemas() == null) { + return Map.of(); + } + Map raw = api.getComponents().getSchemas(); + return (Map>) (Map) raw; + } + + private static Set propertyNames(Schema schema) { + return schema.getProperties() == null + ? Set.of() + : new TreeSet<>(schema.getProperties().keySet()); + } + + private static Set required(Schema schema) { + return schema.getRequired() == null ? Set.of() : new TreeSet<>(schema.getRequired()); + } + + private static Set enumValues(Schema schema) { + if (schema.getEnum() == null) { + return Set.of(); + } + Set values = new TreeSet<>(); + schema.getEnum().forEach(value -> values.add(String.valueOf(value))); + return values; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/openapi/WebOpenApiCustomizer.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/openapi/WebOpenApiCustomizer.java new file mode 100644 index 00000000..55096eda --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/openapi/WebOpenApiCustomizer.java @@ -0,0 +1,74 @@ +package dev.caskeleton.adapter.inbound.web.openapi; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import java.util.Objects; + +/** + * Merges the platform components into the document springdoc generated from the routes. + * + *

Merged rather than replaced. springdoc knows the paths, the request bodies and the response + * types because it reads the handler methods; the platform knows the problem body, the cursor page + * and the security scheme because they are its own contracts. Each contributes what it can see, and + * neither overwrites the other. + * + *

An existing schema is left alone. A deployment that published its own {@code Problem} has made + * a decision, and silently replacing it would change a contract clients already hold. + */ +public final class WebOpenApiCustomizer { + + private final WebOpenApiProfile profile; + + /** + * A customizer over a profile. + * + * @param profile the platform's document identity and components + */ + public WebOpenApiCustomizer(WebOpenApiProfile profile) { + this.profile = Objects.requireNonNull(profile, "profile"); + } + + /** A customizer over the platform defaults. */ + public static WebOpenApiCustomizer standard() { + return new WebOpenApiCustomizer(WebOpenApiProfile.standard()); + } + + /** + * Adds the platform components to a document. + * + * @param api the document springdoc built + * @return the same document, for chaining + */ + public OpenAPI customise(OpenAPI api) { + Objects.requireNonNull(api, "api"); + api.setOpenapi(WebOpenApiProfile.OPENAPI_VERSION); + Components existing = api.getComponents(); + if (existing == null) { + api.setComponents(profile.components()); + return api; + } + Components platform = profile.components(); + if (platform.getSchemas() != null) { + platform + .getSchemas() + .forEach( + (name, schema) -> { + if (existing.getSchemas() == null || !existing.getSchemas().containsKey(name)) { + existing.addSchemas(name, schema); + } + }); + } + if (platform.getSecuritySchemes() != null) { + platform + .getSecuritySchemes() + .forEach( + (name, scheme) -> { + if (existing.getSecuritySchemes() == null + || !existing.getSecuritySchemes().containsKey(name)) { + existing.addSecuritySchemes(name, scheme); + } + }); + } + return api; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/openapi/WebOpenApiDiffResult.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/openapi/WebOpenApiDiffResult.java new file mode 100644 index 00000000..93dbfefd --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/openapi/WebOpenApiDiffResult.java @@ -0,0 +1,39 @@ +package dev.caskeleton.adapter.inbound.web.openapi; + +import java.util.List; +import java.util.Objects; + +/** + * What changed between the released document and the current one. + * + *

Breaking and additive are kept apart rather than reduced to a single verdict, because the two + * lead to different actions: an additive change ships, a breaking one needs a major version. A + * boolean would force the gate to refuse both or allow both. + * + * @param breaking changes that would break a client written against the released document + * @param additive changes a client written against the released document will not notice + */ +public record WebOpenApiDiffResult(List breaking, List additive) { + + public WebOpenApiDiffResult { + Objects.requireNonNull(breaking, "breaking"); + Objects.requireNonNull(additive, "additive"); + breaking = List.copyOf(breaking); + additive = List.copyOf(additive); + } + + /** Nothing changed. */ + public static WebOpenApiDiffResult identical() { + return new WebOpenApiDiffResult(List.of(), List.of()); + } + + /** Whether anything would break a client. */ + public boolean hasBreaking() { + return !breaking.isEmpty(); + } + + /** Whether anything changed at all. */ + public boolean changed() { + return hasBreaking() || !additive.isEmpty(); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/openapi/WebOpenApiProfile.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/openapi/WebOpenApiProfile.java new file mode 100644 index 00000000..71a65f7a --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/openapi/WebOpenApiProfile.java @@ -0,0 +1,72 @@ +package dev.caskeleton.adapter.inbound.web.openapi; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityScheme; +import java.util.Objects; + +/** + * Builds the platform's share of the published OpenAPI document. + * + *

The document is the approved contract artifact, so the parts of it that are platform-wide — + * the problem body, the cursor page, the bearer scheme — are generated from the same values the + * runtime uses rather than written into a YAML file next to them. Two hand-maintained copies of a + * contract disagree, and the copy a client reads is the one that is wrong. + * + *

3.1.2 rather than 3.0: the wire manifest represents a 64-bit integer as a string and a + * nullable field as a union, and 3.0 cannot express either without vendor extensions that no + * generator agrees on. + */ +public final class WebOpenApiProfile { + + /** The OpenAPI version this platform publishes. */ + public static final String OPENAPI_VERSION = "3.1.2"; + + private final String title; + private final String version; + + private WebOpenApiProfile(String title, String version) { + this.title = Objects.requireNonNull(title, "title"); + this.version = Objects.requireNonNull(version, "version"); + } + + /** The platform defaults. */ + public static WebOpenApiProfile standard() { + return new WebOpenApiProfile("Inbound HTTP API", "1.0.0"); + } + + /** + * A profile with an explicit identity. + * + * @param title the document title + * @param version the document version + */ + public static WebOpenApiProfile of(String title, String version) { + return new WebOpenApiProfile(title, version); + } + + /** The document, carrying the platform-wide components. */ + public OpenAPI generate() { + OpenAPI api = new OpenAPI(); + api.setOpenapi(OPENAPI_VERSION); + api.setInfo(new Info().title(title).version(version)); + api.setComponents(components()); + return api; + } + + /** The platform-wide components, for contributing into a document springdoc built. */ + public Components components() { + return new Components() + .addSchemas(ProblemSchemaContributor.SCHEMA_NAME, ProblemSchemaContributor.schema()) + .addSchemas( + ProblemSchemaContributor.ISSUE_SCHEMA_NAME, ProblemSchemaContributor.issueSchema()) + .addSchemas(CursorSchemaContributor.SCHEMA_NAME, CursorSchemaContributor.schema()) + .addSecuritySchemes( + "bearerAuth", + new SecurityScheme() + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT")); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/openapi/WebOpenApiReleaseGate.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/openapi/WebOpenApiReleaseGate.java new file mode 100644 index 00000000..aaf4da55 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/openapi/WebOpenApiReleaseGate.java @@ -0,0 +1,100 @@ +package dev.caskeleton.adapter.inbound.web.openapi; + +import dev.caskeleton.adapter.inbound.web.error.ProblemCatalog; +import io.swagger.v3.oas.models.OpenAPI; +import java.util.List; +import java.util.Objects; + +/** + * The release decision: may this document ship against the approved one? + * + *

A breaking change is not forbidden — it is forbidden outside a major version release. That + * distinction is what keeps the gate usable: a gate that refuses every breaking change gets + * bypassed the first time a real one is needed, and a bypassed gate protects nothing. + * + *

The problem-code check is here rather than in the diff because it compares the document to the + * runtime, not to the previous document. A code the runtime can return and the document + * does not publish becomes a deserialisation failure in every generated client the first time it + * happens, and no amount of comparing documents to each other would notice. + */ +public final class WebOpenApiReleaseGate { + + private final WebOpenApiBreakingPolicy breakingPolicy; + + /** A gate over the platform's breaking policy. */ + public WebOpenApiReleaseGate() { + this(new WebOpenApiBreakingPolicy()); + } + + /** + * A gate over an explicit policy. + * + * @param breakingPolicy decides which changes break a client + */ + public WebOpenApiReleaseGate(WebOpenApiBreakingPolicy breakingPolicy) { + this.breakingPolicy = Objects.requireNonNull(breakingPolicy, "breakingPolicy"); + } + + /** + * Evaluates the current document against the released one. + * + * @param released the approved snapshot + * @param current the document this build produces + * @param majorVersionRelease whether this build is a major version release + * @throws OpenApiReleaseBlockedException when a breaking change ships outside a major release + */ + public WebOpenApiDiffResult evaluate( + OpenAPI released, OpenAPI current, boolean majorVersionRelease) { + WebOpenApiDiffResult diff = breakingPolicy.diff(released, current); + if (diff.hasBreaking() && !majorVersionRelease) { + throw new OpenApiReleaseBlockedException( + "breaking API changes outside a major version release: " + diff.breaking()); + } + return diff; + } + + /** + * Refuses a document whose problem codes are not the runtime's. + * + * @param document the document about to be published + * @param catalog the runtime problem catalog + * @throws OpenApiReleaseBlockedException when the two disagree in either direction + */ + public void requireProblemInventoryMatches(OpenAPI document, ProblemCatalog catalog) { + Objects.requireNonNull(document, "document"); + Objects.requireNonNull(catalog, "catalog"); + List runtime = ProblemSchemaContributor.publishedCodes(catalog); + var schema = + document.getComponents() == null || document.getComponents().getSchemas() == null + ? null + : document.getComponents().getSchemas().get(ProblemSchemaContributor.SCHEMA_NAME); + if (schema == null || schema.getProperties() == null) { + throw new OpenApiReleaseBlockedException( + "the document publishes no Problem schema, so a client has no error contract"); + } + var codeSchema = (io.swagger.v3.oas.models.media.Schema) schema.getProperties().get("code"); + if (codeSchema == null || codeSchema.getEnum() == null) { + throw new OpenApiReleaseBlockedException("the Problem schema publishes no code enum"); + } + List published = codeSchema.getEnum().stream().map(String::valueOf).sorted().toList(); + if (!published.equals(runtime)) { + throw new OpenApiReleaseBlockedException( + "the published problem codes are not the runtime's; a code the runtime returns and the" + + " document omits is a deserialisation failure in every generated client. published=" + + published + + " runtime=" + + runtime); + } + } + + /** The release was refused. */ + public static final class OpenApiReleaseBlockedException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + /** Creates the failure. */ + public OpenApiReleaseBlockedException(String message) { + super(message); + } + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/operation/AdmissionProfileName.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/operation/AdmissionProfileName.java new file mode 100644 index 00000000..f333b4ea --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/operation/AdmissionProfileName.java @@ -0,0 +1,35 @@ +package dev.caskeleton.adapter.inbound.web.operation; + +import java.util.regex.Pattern; + +/** + * The name of the rate limit and concurrency admission an operation is subject to. + * + *

A name rather than the policy itself. An operation profile is a declaration written once; the + * policy behind the name is a deployment decision that changes without the operation changing, and + * naming it lets a startup check report which policy is missing instead of failing at the first + * request that needed it. + * + * @param value the profile name, matching {@code [a-z][a-z0-9.-]{2,63}} + */ +public record AdmissionProfileName(String value) { + + private static final Pattern GRAMMAR = Pattern.compile("[a-z][a-z0-9.-]{2,63}"); + + public AdmissionProfileName { + if (value == null || !GRAMMAR.matcher(value).matches()) { + throw new IllegalArgumentException( + "profile name must match [a-z][a-z0-9.-]{2,63}, was: " + value); + } + } + + /** The profile an operation that names none falls back to. */ + public static AdmissionProfileName standard() { + return new AdmissionProfileName("default"); + } + + @Override + public String toString() { + return value; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/operation/AuthorizationProfileName.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/operation/AuthorizationProfileName.java new file mode 100644 index 00000000..6a9b1ebf --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/operation/AuthorizationProfileName.java @@ -0,0 +1,35 @@ +package dev.caskeleton.adapter.inbound.web.operation; + +import java.util.regex.Pattern; + +/** + * The name of the authorization rules an operation is evaluated against. + * + *

A name rather than the policy itself. An operation profile is a declaration written once; the + * policy behind the name is a deployment decision that changes without the operation changing, and + * naming it lets a startup check report which policy is missing instead of failing at the first + * request that needed it. + * + * @param value the profile name, matching {@code [a-z][a-z0-9.-]{2,63}} + */ +public record AuthorizationProfileName(String value) { + + private static final Pattern GRAMMAR = Pattern.compile("[a-z][a-z0-9.-]{2,63}"); + + public AuthorizationProfileName { + if (value == null || !GRAMMAR.matcher(value).matches()) { + throw new IllegalArgumentException( + "profile name must match [a-z][a-z0-9.-]{2,63}, was: " + value); + } + } + + /** The profile an operation that names none falls back to. */ + public static AuthorizationProfileName standard() { + return new AuthorizationProfileName("public"); + } + + @Override + public String toString() { + return value; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/operation/CachePolicyName.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/operation/CachePolicyName.java new file mode 100644 index 00000000..d9715d2f --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/operation/CachePolicyName.java @@ -0,0 +1,35 @@ +package dev.caskeleton.adapter.inbound.web.operation; + +import java.util.regex.Pattern; + +/** + * The name of the cache-control and Vary policy an operation's responses carry. + * + *

A name rather than the policy itself. An operation profile is a declaration written once; the + * policy behind the name is a deployment decision that changes without the operation changing, and + * naming it lets a startup check report which policy is missing instead of failing at the first + * request that needed it. + * + * @param value the profile name, matching {@code [a-z][a-z0-9.-]{2,63}} + */ +public record CachePolicyName(String value) { + + private static final Pattern GRAMMAR = Pattern.compile("[a-z][a-z0-9.-]{2,63}"); + + public CachePolicyName { + if (value == null || !GRAMMAR.matcher(value).matches()) { + throw new IllegalArgumentException( + "profile name must match [a-z][a-z0-9.-]{2,63}, was: " + value); + } + } + + /** The profile an operation that names none falls back to. */ + public static CachePolicyName standard() { + return new CachePolicyName("no-store"); + } + + @Override + public String toString() { + return value; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/operation/HttpMethodSemantic.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/operation/HttpMethodSemantic.java new file mode 100644 index 00000000..1d36292c --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/operation/HttpMethodSemantic.java @@ -0,0 +1,54 @@ +package dev.caskeleton.adapter.inbound.web.operation; + +/** + * The HTTP method an operation is reached by, with the two properties that actually decide policy. + * + *

Safety and idempotence are carried as fields rather than re-derived at each call site, because + * they are the inputs to three separate decisions — whether a response may be cached, whether a + * precondition is required, whether a retry is allowed — and three independent re-derivations is + * three chances to get {@code POST} wrong. + * + *

Note that idempotent does not mean safe: {@code DELETE} changes state and repeating it is + * still harmless. Conflating them is why a platform ends up caching a delete. + */ +public enum HttpMethodSemantic { + + /** Read-only and repeatable. */ + GET(true, true), + + /** Read-only and repeatable; a GET without the body. */ + HEAD(true, true), + + /** Read-only and repeatable; describes the resource's capabilities. */ + OPTIONS(true, true), + + /** Changes state and is not repeatable: the method idempotency keys exist for. */ + POST(false, false), + + /** Changes state to a caller-supplied whole, so repeating it lands on the same state. */ + PUT(false, true), + + /** Changes state by a caller-supplied delta, which repeating need not reproduce. */ + PATCH(false, false), + + /** Changes state; repeating it leaves the resource just as absent. */ + DELETE(false, true); + + private final boolean safe; + private final boolean idempotent; + + HttpMethodSemantic(boolean safe, boolean idempotent) { + this.safe = safe; + this.idempotent = idempotent; + } + + /** Whether the method is defined to leave state unchanged. */ + public boolean safe() { + return safe; + } + + /** Whether repeating the request has the same effect as making it once. */ + public boolean idempotent() { + return idempotent; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/operation/IdempotencyPolicy.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/operation/IdempotencyPolicy.java new file mode 100644 index 00000000..3a0f6f57 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/operation/IdempotencyPolicy.java @@ -0,0 +1,20 @@ +package dev.caskeleton.adapter.inbound.web.operation; + +/** + * Whether an operation accepts, requires or refuses an idempotency key. + * + *

{@link #FORBIDDEN} is a real state rather than the absence of a policy. A read that accepts an + * idempotency key invites a client to believe its repeated read is being deduplicated, and the + * platform would be storing replay records for responses it never needs to replay. + */ +public enum IdempotencyPolicy { + + /** The key is refused; sending one is a client error. */ + FORBIDDEN, + + /** The key is honoured when present and the operation runs normally when it is not. */ + OPTIONAL, + + /** The key is mandatory; a request without one is refused before the application is entered. */ + REQUIRED +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/operation/InMemoryWebOperationCatalog.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/operation/InMemoryWebOperationCatalog.java new file mode 100644 index 00000000..dc03e5d2 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/operation/InMemoryWebOperationCatalog.java @@ -0,0 +1,61 @@ +package dev.caskeleton.adapter.inbound.web.operation; + +import dev.caskeleton.adapter.inbound.web.core.WebOperationName; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.TreeSet; +import java.util.concurrent.ConcurrentHashMap; + +/** + * The catalog a single deployment assembles at startup. + * + *

In memory because the operation set is a property of the build, not of the environment: it is + * fixed the moment the routes are compiled, and reading it from a datastore would let a deployment + * serve an operation the code does not implement. + * + *

Duplicate registration is refused rather than overwritten. Two profiles for one name means the + * one that registered last silently wins, and the review that approved the other described a policy + * that never took effect. + */ +public final class InMemoryWebOperationCatalog implements WebOperationCatalog { + + private final Map profiles = new ConcurrentHashMap<>(); + + @Override + public void register(WebOperationProfile profile) { + Objects.requireNonNull(profile, "profile"); + if (profiles.putIfAbsent(profile.operationName(), profile) != null) { + throw new IllegalStateException( + "duplicate web operation: " + + profile.operationName() + + "; the profile registered second would silently replace the reviewed one"); + } + } + + @Override + public WebOperationProfile require(WebOperationName operationName) { + Objects.requireNonNull(operationName, "operationName"); + WebOperationProfile profile = profiles.get(operationName); + if (profile == null) { + throw new IllegalArgumentException( + "unknown web operation: " + + operationName + + "; a route with no registered profile has no budget, authorization or" + + " idempotency policy"); + } + return profile; + } + + /** Every registered operation name, sorted, for a startup report. */ + public Set registeredNames() { + Set names = new TreeSet<>(); + profiles.keySet().forEach(name -> names.add(name.value())); + return Set.copyOf(names); + } + + /** How many operations are registered. */ + public int size() { + return profiles.size(); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/operation/MutationKind.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/operation/MutationKind.java new file mode 100644 index 00000000..826c2609 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/operation/MutationKind.java @@ -0,0 +1,38 @@ +package dev.caskeleton.adapter.inbound.web.operation; + +/** + * What an operation does to state, independent of the method it is reached by. + * + *

Separate from {@link HttpMethodSemantic} because the two disagree often enough to matter. A + * {@code POST} that only searches is {@link #READ_ONLY}; a {@code POST} that starts a long-running + * job is {@link #DURABLE_ASYNC} and needs an operation resource rather than a result. Deciding + * idempotency and cache policy from the method alone gets both of those wrong. + */ +public enum MutationKind { + + /** Reads only; may be cached, needs no idempotency key. */ + READ_ONLY, + + /** Creates a new resource; the classic case for an idempotency key. */ + CREATE, + + /** Replaces a resource wholesale; naturally idempotent given a precondition. */ + REPLACE, + + /** Applies a delta to a resource; needs a precondition to be safe to repeat. */ + PARTIAL_UPDATE, + + /** Removes a resource. */ + DELETE, + + /** Runs a business command that is neither a plain create nor a plain update. */ + COMMAND, + + /** Accepts work and answers with a durable operation resource rather than a result. */ + DURABLE_ASYNC; + + /** Whether this kind changes state. */ + public boolean mutating() { + return this != READ_ONLY; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/operation/PreconditionPolicy.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/operation/PreconditionPolicy.java new file mode 100644 index 00000000..916ad6ad --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/operation/PreconditionPolicy.java @@ -0,0 +1,21 @@ +package dev.caskeleton.adapter.inbound.web.operation; + +/** + * Whether an operation requires a conditional header before it will change state. + * + *

Distinct from idempotency and deliberately so: {@code If-Match} is concurrency control — "the + * resource you read is still the resource you are writing over" — while an idempotency key is + * duplicate-execution control. An operation can need either, both or neither, and a platform that + * treats them as one setting cannot express the lost-update it was supposed to prevent. + */ +public enum PreconditionPolicy { + + /** No conditional header is consulted. */ + NONE, + + /** A conditional header is honoured when present. */ + OPTIONAL, + + /** {@code If-Match} is mandatory; a request without one is refused with 428. */ + REQUIRED +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/operation/ResponseProfileName.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/operation/ResponseProfileName.java new file mode 100644 index 00000000..1c17d83e --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/operation/ResponseProfileName.java @@ -0,0 +1,35 @@ +package dev.caskeleton.adapter.inbound.web.operation; + +import java.util.regex.Pattern; + +/** + * The name of the success status, headers and body shape an operation produces. + * + *

A name rather than the policy itself. An operation profile is a declaration written once; the + * policy behind the name is a deployment decision that changes without the operation changing, and + * naming it lets a startup check report which policy is missing instead of failing at the first + * request that needed it. + * + * @param value the profile name, matching {@code [a-z][a-z0-9.-]{2,63}} + */ +public record ResponseProfileName(String value) { + + private static final Pattern GRAMMAR = Pattern.compile("[a-z][a-z0-9.-]{2,63}"); + + public ResponseProfileName { + if (value == null || !GRAMMAR.matcher(value).matches()) { + throw new IllegalArgumentException( + "profile name must match [a-z][a-z0-9.-]{2,63}, was: " + value); + } + } + + /** The profile an operation that names none falls back to. */ + public static ResponseProfileName standard() { + return new ResponseProfileName("resource"); + } + + @Override + public String toString() { + return value; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/operation/WebOperationCatalog.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/operation/WebOperationCatalog.java new file mode 100644 index 00000000..53572651 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/operation/WebOperationCatalog.java @@ -0,0 +1,28 @@ +package dev.caskeleton.adapter.inbound.web.operation; + +import dev.caskeleton.adapter.inbound.web.core.WebOperationName; + +/** + * The registry of every operation this deployment serves. + * + *

{@link #require} rather than a lookup returning empty, because an unregistered operation is + * not a miss to be handled — it is a route that reached production without a budget, an + * authorization profile or an idempotency policy, and serving it with defaults is how those + * settings stop being decisions. + */ +public interface WebOperationCatalog { + + /** + * The profile for an operation. + * + * @throws IllegalArgumentException when the operation was never registered + */ + WebOperationProfile require(WebOperationName operationName); + + /** + * Registers an operation profile. + * + * @throws IllegalStateException when the operation name is already registered + */ + void register(WebOperationProfile profile); +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/operation/WebOperationProfile.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/operation/WebOperationProfile.java new file mode 100644 index 00000000..225984be --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/operation/WebOperationProfile.java @@ -0,0 +1,119 @@ +package dev.caskeleton.adapter.inbound.web.operation; + +import dev.caskeleton.adapter.inbound.web.budget.WebBudgetProfileName; +import dev.caskeleton.adapter.inbound.web.core.WebOperationName; +import java.util.Objects; + +/** + * Everything the platform needs to know about one operation before it runs. + * + *

Ten declarations in one value, because each of them is consulted by a different stage and the + * question "is this combination coherent?" can only be asked when they are together. A read that + * requires an idempotency key, a mutation with a cache policy, a partial update with no + * precondition — each is a mistake that is invisible while the settings live in separate places and + * obvious the moment they are one record. The constructor asks that question, so an incoherent + * operation fails at startup rather than at the first request that exercises the contradiction. + * + * @param operationName the low-cardinality identity every other subsystem keys on + * @param method the HTTP method the operation is reached by + * @param mutationKind what the operation does to state + * @param requestBudget the registered budget profile the operation runs under + * @param authorization the registered authorization profile + * @param idempotency whether an idempotency key is refused, accepted or required + * @param precondition whether a conditional header is required + * @param cachePolicy the registered cache policy + * @param admission the registered admission profile + * @param responseProfile the registered response shape + */ +public record WebOperationProfile( + WebOperationName operationName, + HttpMethodSemantic method, + MutationKind mutationKind, + WebBudgetProfileName requestBudget, + AuthorizationProfileName authorization, + IdempotencyPolicy idempotency, + PreconditionPolicy precondition, + CachePolicyName cachePolicy, + AdmissionProfileName admission, + ResponseProfileName responseProfile) { + + public WebOperationProfile { + Objects.requireNonNull(operationName, "operationName"); + Objects.requireNonNull(method, "method"); + Objects.requireNonNull(mutationKind, "mutationKind"); + Objects.requireNonNull(requestBudget, "requestBudget"); + Objects.requireNonNull(authorization, "authorization"); + Objects.requireNonNull(idempotency, "idempotency"); + Objects.requireNonNull(precondition, "precondition"); + Objects.requireNonNull(cachePolicy, "cachePolicy"); + Objects.requireNonNull(admission, "admission"); + Objects.requireNonNull(responseProfile, "responseProfile"); + + // A read-only operation with an idempotency requirement is the incoherence this record exists + // to catch. There is nothing to deduplicate, so the platform would store a replay record for a + // response it never replays, and a client would reasonably believe its repeated read was being + // collapsed. The design names it as a startup failure; this is where it fails. + if (!mutationKind.mutating() && idempotency != IdempotencyPolicy.FORBIDDEN) { + throw new IllegalArgumentException( + "read-only operation " + + operationName + + " declares idempotency " + + idempotency + + "; only a mutating operation has duplicate execution to prevent"); + } + if (!mutationKind.mutating() && precondition == PreconditionPolicy.REQUIRED) { + throw new IllegalArgumentException( + "read-only operation " + + operationName + + " requires If-Match; a read has no lost update to prevent"); + } + if (mutationKind.mutating() && method.safe()) { + throw new IllegalArgumentException( + "operation " + + operationName + + " mutates state behind the safe method " + + method + + "; a caches-and-prefetches intermediary will replay it"); + } + } + + /** + * A read-only operation with the platform defaults. + * + *

The factory the design's own example uses. It exists so the common case cannot get the + * idempotency and precondition pairing wrong by hand. + */ + public static WebOperationProfile readOnly(String operationName) { + return new WebOperationProfile( + new WebOperationName(operationName), + HttpMethodSemantic.GET, + MutationKind.READ_ONLY, + WebBudgetProfileName.standard(), + AuthorizationProfileName.standard(), + IdempotencyPolicy.FORBIDDEN, + PreconditionPolicy.NONE, + CachePolicyName.standard(), + AdmissionProfileName.standard(), + ResponseProfileName.standard()); + } + + /** A creating operation with the platform defaults and a required idempotency key. */ + public static WebOperationProfile create(String operationName) { + return new WebOperationProfile( + new WebOperationName(operationName), + HttpMethodSemantic.POST, + MutationKind.CREATE, + WebBudgetProfileName.standard(), + AuthorizationProfileName.standard(), + IdempotencyPolicy.REQUIRED, + PreconditionPolicy.NONE, + CachePolicyName.standard(), + AdmissionProfileName.standard(), + ResponseProfileName.standard()); + } + + /** Whether this operation changes state. */ + public boolean mutating() { + return mutationKind.mutating(); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/operationasync/OperationAccessPolicy.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/operationasync/OperationAccessPolicy.java new file mode 100644 index 00000000..1bc8022b --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/operationasync/OperationAccessPolicy.java @@ -0,0 +1,42 @@ +package dev.caskeleton.adapter.inbound.web.operationasync; + +import dev.caskeleton.adapter.inbound.web.core.WebRequestContext; +import dev.caskeleton.application.operation.DurableOperation; +import java.util.Objects; + +/** + * Who may see an operation. + * + *

Object-level, not route-level. Being allowed to call {@code GET /operations/{id}} says nothing + * about being allowed to see *this* operation, and a route-level check alone turns a guessable id + * into a way to read somebody else's work — including, for a failed one, its problem detail. + * + *

Absent and forbidden are answered identically on purpose. Telling an unauthorised caller that + * an operation exists is itself the disclosure: it turns id enumeration into a working oracle for + * what other tenants are running. + */ +public final class OperationAccessPolicy { + + private OperationAccessPolicy() {} + + /** + * Whether this request may see this operation. + * + * @param operation the stored operation + * @param context the request asking + */ + public static boolean mayAccess(DurableOperation operation, WebRequestContext context) { + Objects.requireNonNull(operation, "operation"); + Objects.requireNonNull(context, "context"); + if (!context.actor().authenticated()) { + return false; + } + if (!operation.principal().equals(context.actor().subject())) { + return false; + } + String tenant = operation.tenantId(); + return tenant == null + ? context.tenant().value().isEmpty() + : context.tenant().value().filter(tenant::equals).isPresent(); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/operationasync/OperationId.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/operationasync/OperationId.java new file mode 100644 index 00000000..ee641d0b --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/operationasync/OperationId.java @@ -0,0 +1,29 @@ +package dev.caskeleton.adapter.inbound.web.operationasync; + +import java.util.Objects; +import java.util.regex.Pattern; + +/** + * The identity of one durable operation, as it appears in a URL. + * + * @param value the identifier + */ +public record OperationId(String value) { + + // URL-safe and bounded. The identifier is handed back in a Location header and polled by clients + // for as long as the operation lives, so anything that needs escaping is a defect that only + // shows up in whichever client escapes it differently. + private static final Pattern GRAMMAR = Pattern.compile("[A-Za-z0-9._~-]{1,128}"); + + public OperationId { + Objects.requireNonNull(value, "value"); + if (!GRAMMAR.matcher(value).matches()) { + throw new IllegalArgumentException("an operation id must match [A-Za-z0-9._~-]{1,128}"); + } + } + + @Override + public String toString() { + return value; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/operationasync/OperationProgress.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/operationasync/OperationProgress.java new file mode 100644 index 00000000..1c2a0d2d --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/operationasync/OperationProgress.java @@ -0,0 +1,50 @@ +package dev.caskeleton.adapter.inbound.web.operationasync; + +import java.util.Objects; +import java.util.Optional; + +/** + * How far a running operation has got. + * + *

Both counts are optional because most operations honestly cannot say. A platform that demands + * a percentage gets one invented, and an invented percentage is worse than no percentage: clients + * build progress bars on it and users read a lie. + * + * @param completedUnits work finished so far + * @param totalUnits work expected in total, when it is known ahead of time + * @param phase a short label for the current stage, when the operation has stages + */ +public record OperationProgress( + long completedUnits, Optional totalUnits, Optional phase) { + + public OperationProgress { + Objects.requireNonNull(totalUnits, "totalUnits"); + Objects.requireNonNull(phase, "phase"); + if (completedUnits < 0) { + throw new IllegalArgumentException("completed units cannot be negative"); + } + if (totalUnits.isPresent() && totalUnits.get() < completedUnits) { + throw new IllegalArgumentException( + "an operation cannot have completed more units than it has: " + + completedUnits + + " of " + + totalUnits.get()); + } + phase.ifPresent( + value -> { + if (value.isBlank()) { + throw new IllegalArgumentException("a phase label that is blank is not a label"); + } + }); + } + + /** Progress with no total and no phase. */ + public static OperationProgress of(long completedUnits) { + return new OperationProgress(completedUnits, Optional.empty(), Optional.empty()); + } + + /** The completed fraction, when a total is known. */ + public Optional fraction() { + return totalUnits.map(total -> total == 0 ? 1.0 : (double) completedUnits / total); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/operationasync/OperationQueryService.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/operationasync/OperationQueryService.java new file mode 100644 index 00000000..ba9351e2 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/operationasync/OperationQueryService.java @@ -0,0 +1,74 @@ +package dev.caskeleton.adapter.inbound.web.operationasync; + +import dev.caskeleton.adapter.inbound.web.core.WebRequestContext; +import dev.caskeleton.application.operation.DurableOperation; +import dev.caskeleton.application.operation.DurableOperationId; +import dev.caskeleton.application.operation.DurableOperationStorePort; +import java.time.Clock; +import java.util.Objects; +import java.util.Optional; + +/** + * Reads and cancels operations on behalf of an authenticated request. + * + *

The authorization check lives here rather than in each controller so the servlet and reactive + * routes cannot disagree about it. Two copies of an access rule is one copy that eventually gets a + * fix the other does not. + */ +public final class OperationQueryService { + + private final DurableOperationStorePort store; + private final OperationResourceFactory resources; + private final Clock clock; + + /** + * A service over the operation store. + * + * @param store where operations live + * @param resources projects a stored operation into its polled document + * @param clock the clock expiry is judged against + */ + public OperationQueryService( + DurableOperationStorePort store, OperationResourceFactory resources, Clock clock) { + this.store = Objects.requireNonNull(store, "store"); + this.resources = Objects.requireNonNull(resources, "resources"); + this.clock = Objects.requireNonNull(clock, "clock"); + } + + /** + * The operation this request may see, or empty. + * + * @param operationId the identity being polled + * @param context the request asking + */ + public Optional find(OperationId operationId, WebRequestContext context) { + Objects.requireNonNull(operationId, "operationId"); + Objects.requireNonNull(context, "context"); + return store + .find( + new DurableOperationId(operationId.value()), context.actor().subject(), clock.instant()) + .filter(operation -> OperationAccessPolicy.mayAccess(operation, context)) + .map(operation -> resources.resourceFor(operation, context.traceId().value())); + } + + /** + * Cancels an operation this request may see. + * + * @return empty when the caller may not see it, otherwise whether this call cancelled it + */ + public Optional cancel(OperationId operationId, WebRequestContext context) { + Objects.requireNonNull(operationId, "operationId"); + Objects.requireNonNull(context, "context"); + DurableOperationId id = new DurableOperationId(operationId.value()); + Optional operation = + store + .find(id, context.actor().subject(), clock.instant()) + .filter(candidate -> OperationAccessPolicy.mayAccess(candidate, context)); + if (operation.isEmpty()) { + return Optional.empty(); + } + // The store decides, not this check: between the read above and the cancel below the operation + // can finish, and only the store's own state predicate can refuse that race atomically. + return Optional.of(store.cancel(id, context.actor().subject(), clock.instant())); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/operationasync/OperationResource.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/operationasync/OperationResource.java new file mode 100644 index 00000000..37a69e38 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/operationasync/OperationResource.java @@ -0,0 +1,159 @@ +package dev.caskeleton.adapter.inbound.web.operationasync; + +import dev.caskeleton.adapter.inbound.web.error.WebProblem; +import java.net.URI; +import java.time.Duration; +import java.time.Instant; +import java.util.Objects; +import java.util.Optional; + +/** + * The published state of one durable operation. + * + *

Durable is the whole point. A {@code CompletableFuture} or an {@code @Async} handle is a + * process-local object: it disappears when the node that made it restarts, and the client polling + * for it gets a 404 for work that is still running somewhere. This record is what a client polls, + * and everything in it survives the process. + * + *

The constructor refuses the incoherent combinations rather than trusting callers, because each + * one is a specific broken client experience: a SUCCEEDED operation with nowhere to read the result + * is a success a caller cannot use, and a FAILED one with no problem document is an error with no + * explanation. + * + * @param operationId the identity clients poll + * @param status where in the lifecycle it is + * @param createdAt when it was accepted + * @param startedAt when a worker picked it up + * @param completedAt when it reached a terminal state + * @param progress how far it has got, when that is knowable + * @param resultLocation where a successful result is read from + * @param problem why it failed, sanitised for publication + * @param retryAfter how long a poller should wait before asking again + * @param expiresAt when this record stops being available + */ +public record OperationResource( + OperationId operationId, + OperationStatus status, + Instant createdAt, + Optional startedAt, + Optional completedAt, + Optional progress, + Optional resultLocation, + Optional problem, + Optional retryAfter, + Instant expiresAt) { + + public OperationResource { + Objects.requireNonNull(operationId, "operationId"); + Objects.requireNonNull(status, "status"); + Objects.requireNonNull(createdAt, "createdAt"); + Objects.requireNonNull(startedAt, "startedAt"); + Objects.requireNonNull(completedAt, "completedAt"); + Objects.requireNonNull(progress, "progress"); + Objects.requireNonNull(resultLocation, "resultLocation"); + Objects.requireNonNull(problem, "problem"); + Objects.requireNonNull(retryAfter, "retryAfter"); + Objects.requireNonNull(expiresAt, "expiresAt"); + + if (status == OperationStatus.SUCCEEDED && resultLocation.isEmpty()) { + throw new IllegalArgumentException( + "a SUCCEEDED operation must say where its result is; a success the caller cannot read is" + + " indistinguishable from a lost one"); + } + if (status == OperationStatus.FAILED && problem.isEmpty()) { + throw new IllegalArgumentException( + "a FAILED operation must carry a problem document; an unexplained failure gives the" + + " caller nothing to branch on and nothing to report"); + } + if (status != OperationStatus.FAILED && problem.isPresent()) { + throw new IllegalArgumentException( + "only a FAILED operation carries a problem document, was " + status); + } + if (status.terminal() && completedAt.isEmpty()) { + throw new IllegalArgumentException("a " + status + " operation must say when it finished"); + } + if (!status.terminal() && completedAt.isPresent()) { + throw new IllegalArgumentException( + "a " + status + " operation has not finished, so it has no completion time"); + } + if (status == OperationStatus.PENDING && startedAt.isPresent()) { + throw new IllegalArgumentException("a PENDING operation has not started"); + } + if (status == OperationStatus.RUNNING && startedAt.isEmpty()) { + throw new IllegalArgumentException("a RUNNING operation must say when it started"); + } + if (!expiresAt.isAfter(createdAt)) { + throw new IllegalArgumentException("an operation that expires when it is created is not one"); + } + startedAt.ifPresent( + started -> { + if (started.isBefore(createdAt)) { + throw new IllegalArgumentException("an operation cannot start before it was accepted"); + } + }); + completedAt.ifPresent( + completed -> { + if (completed.isBefore(createdAt)) { + throw new IllegalArgumentException("an operation cannot finish before it was accepted"); + } + }); + } + + /** A freshly accepted operation. */ + public static OperationResource pending( + OperationId operationId, Instant createdAt, Instant expiresAt, Duration retryAfter) { + return new OperationResource( + operationId, + OperationStatus.PENDING, + createdAt, + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.ofNullable(retryAfter), + expiresAt); + } + + /** An operation a worker has picked up. */ + public OperationResource running(Instant startedAt, OperationProgress progress) { + return new OperationResource( + operationId, + OperationStatus.RUNNING, + createdAt, + Optional.of(startedAt), + Optional.empty(), + Optional.ofNullable(progress), + Optional.empty(), + Optional.empty(), + retryAfter, + expiresAt); + } + + /** + * A finished operation and where to read its result. + * + * @param operationId the identity + * @param completedAt when it finished + * @param resultLocation where the result is read from + */ + public static OperationResource succeeded( + OperationId operationId, Instant completedAt, Optional resultLocation) { + return new OperationResource( + operationId, + OperationStatus.SUCCEEDED, + completedAt, + Optional.of(completedAt), + Optional.of(completedAt), + Optional.empty(), + resultLocation, + Optional.empty(), + Optional.empty(), + completedAt.plus(Duration.ofDays(1))); + } + + /** Whether a poller should stop polling. */ + public boolean finished() { + return status.terminal(); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/operationasync/OperationResourceFactory.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/operationasync/OperationResourceFactory.java new file mode 100644 index 00000000..441aaca5 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/operationasync/OperationResourceFactory.java @@ -0,0 +1,112 @@ +package dev.caskeleton.adapter.inbound.web.operationasync; + +import dev.caskeleton.adapter.inbound.web.error.ProblemCode; +import dev.caskeleton.adapter.inbound.web.error.WebProblemFactory; +import dev.caskeleton.application.operation.DurableOperation; +import dev.caskeleton.application.operation.DurableOperationState; +import dev.caskeleton.application.operation.OperationFailure; +import java.net.URI; +import java.time.Duration; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.Optional; + +/** + * Projects a stored operation into the document a client polls. + * + *

A projection rather than a shared type, because the registry does not let the JPA adapter see + * a web type and does not let this leaf see a persistence one. The application owns {@link + * DurableOperation}; this leaf owns what an operation looks like over HTTP, including the problem + * document, which is an HTTP concept and has no business being persisted. + * + *

That split is also what keeps the stored failure publishable. {@link OperationFailure} carries + * a code and an already-safe message; turning it into a {@code WebProblem} here runs it through the + * same catalog and the same sanitiser as every synchronous error, so an async failure cannot + * publish something a synchronous one would have redacted. + */ +public final class OperationResourceFactory { + + private final WebProblemFactory problems; + private final URI resultBase; + private final Duration pollInterval; + + /** + * A factory for one API. + * + * @param problems the problem document factory + * @param resultBase the prefix a successful result is read from + * @param pollInterval what an unfinished operation advertises as {@code Retry-After} + */ + public OperationResourceFactory( + WebProblemFactory problems, URI resultBase, Duration pollInterval) { + this.problems = Objects.requireNonNull(problems, "problems"); + this.resultBase = Objects.requireNonNull(resultBase, "resultBase"); + this.pollInterval = Objects.requireNonNull(pollInterval, "pollInterval"); + if (pollInterval.isZero() || pollInterval.isNegative()) { + throw new IllegalArgumentException( + "a poll interval of zero invites a client to spin as fast as it can"); + } + } + + /** + * The polled document for one stored operation. + * + * @param operation the stored operation + * @param traceId the correlation identifier of the poll + */ + public OperationResource resourceFor(DurableOperation operation, String traceId) { + Objects.requireNonNull(operation, "operation"); + Objects.requireNonNull(traceId, "traceId"); + OperationId operationId = new OperationId(operation.operationId().value()); + return new OperationResource( + operationId, + statusOf(operation.state()), + operation.submittedAt(), + operation.startedAt(), + operation.completedAt(), + operation.progress().map(OperationResourceFactory::progressOf), + operation.resultReference().map(reference -> resultBase.resolve(reference)), + operation.failure().map(failure -> problemOf(failure, operationId, traceId)), + operation.finished() ? Optional.empty() : Optional.of(pollInterval), + operation.expiresAt()); + } + + private static OperationStatus statusOf(DurableOperationState state) { + return switch (state) { + case PENDING -> OperationStatus.PENDING; + case RUNNING -> OperationStatus.RUNNING; + case SUCCEEDED -> OperationStatus.SUCCEEDED; + case FAILED -> OperationStatus.FAILED; + case CANCELED -> OperationStatus.CANCELED; + case EXPIRED -> OperationStatus.EXPIRED; + }; + } + + private static OperationProgress progressOf( + dev.caskeleton.application.operation.OperationProgressSnapshot snapshot) { + return new OperationProgress( + snapshot.completedUnits(), snapshot.totalUnits(), snapshot.phase()); + } + + private dev.caskeleton.adapter.inbound.web.error.WebProblem problemOf( + OperationFailure failure, OperationId operationId, String traceId) { + return problems.create( + codeOf(failure), + failure.detail(), + URI.create("/operations/" + operationId.value()), + traceId, + List.of()); + } + + private static ProblemCode codeOf(OperationFailure failure) { + // An unrecognised code becomes INTERNAL_ERROR rather than being echoed. The catalog is the + // published vocabulary; a worker that invents a code must not be able to extend it by writing + // a row, and a client branching on codes must never see one that is not in the contract. + try { + return ProblemCode.valueOf(failure.code().toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException unknown) { + return ProblemCode.INTERNAL_ERROR; + } + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/operationasync/OperationResponse.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/operationasync/OperationResponse.java new file mode 100644 index 00000000..c3c340fe --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/operationasync/OperationResponse.java @@ -0,0 +1,56 @@ +package dev.caskeleton.adapter.inbound.web.operationasync; + +import dev.caskeleton.adapter.inbound.web.error.WebProblem; +import java.time.Instant; +import java.util.Objects; + +/** + * The wire form of a polled operation. + * + *

Separate from {@link OperationResource} because a record built to enforce invariants and a + * record built to be serialized want different things. The resource keeps {@code Optional} fields, + * which is right for a type whose constructor refuses incoherent combinations and wrong for a JSON + * document, where an absent field should simply be absent. + * + *

Nulls here therefore mean "not applicable yet", and the mapper is the only thing that produces + * them — after the resource has already refused the combinations that would make a null wrong. + * + * @param operationId the identity + * @param status where in the lifecycle it is + * @param createdAt when it was accepted + * @param startedAt when a worker picked it up, or null + * @param completedAt when it finished, or null + * @param progress how far it has got, or null + * @param resultLocation where the result is read, or null + * @param problem why it failed, or null + * @param retryAfterSeconds how long to wait before polling again, or null when finished + * @param expiresAt when the record stops being available + */ +public record OperationResponse( + String operationId, + String status, + Instant createdAt, + Instant startedAt, + Instant completedAt, + ProgressResponse progress, + String resultLocation, + WebProblem problem, + Long retryAfterSeconds, + Instant expiresAt) { + + public OperationResponse { + Objects.requireNonNull(operationId, "operationId"); + Objects.requireNonNull(status, "status"); + Objects.requireNonNull(createdAt, "createdAt"); + Objects.requireNonNull(expiresAt, "expiresAt"); + } + + /** + * The wire form of progress. + * + * @param completedUnits work finished + * @param totalUnits work expected, or null when unknown + * @param phase the current stage, or null + */ + public record ProgressResponse(long completedUnits, Long totalUnits, String phase) {} +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/operationasync/OperationResponseMapper.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/operationasync/OperationResponseMapper.java new file mode 100644 index 00000000..00453ac2 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/operationasync/OperationResponseMapper.java @@ -0,0 +1,43 @@ +package dev.caskeleton.adapter.inbound.web.operationasync; + +import java.util.Objects; + +/** + * Turns a resource into its wire form. + * + *

A separate step rather than serialization annotations on the resource, so the type that + * enforces the lifecycle invariants stays free of any opinion about JSON. It also means the wire + * shape can change for a new API major version without touching the invariants. + */ +public final class OperationResponseMapper { + + private OperationResponseMapper() {} + + /** + * The wire form of a resource. + * + * @param resource the polled operation + */ + public static OperationResponse from(OperationResource resource) { + Objects.requireNonNull(resource, "resource"); + return new OperationResponse( + resource.operationId().value(), + resource.status().name(), + resource.createdAt(), + resource.startedAt().orElse(null), + resource.completedAt().orElse(null), + resource + .progress() + .map( + progress -> + new OperationResponse.ProgressResponse( + progress.completedUnits(), + progress.totalUnits().orElse(null), + progress.phase().orElse(null))) + .orElse(null), + resource.resultLocation().map(Object::toString).orElse(null), + resource.problem().orElse(null), + resource.retryAfter().map(java.time.Duration::toSeconds).orElse(null), + resource.expiresAt()); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/operationasync/OperationStatus.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/operationasync/OperationStatus.java new file mode 100644 index 00000000..fd905407 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/operationasync/OperationStatus.java @@ -0,0 +1,40 @@ +package dev.caskeleton.adapter.inbound.web.operationasync; + +/** + * The lifecycle of a durable operation. + * + *

Six states rather than "done / not done", because a client polling an operation has to decide + * something different for each of them: keep waiting, read the result, show an error, stop because + * somebody cancelled, or start over because the record aged out. Collapsing any two of them moves + * that decision into guesswork. + */ +public enum OperationStatus { + + /** Accepted and durable, not yet picked up by a worker. */ + PENDING, + + /** A worker holds the lease and is executing. */ + RUNNING, + + /** Finished; the result is retrievable. */ + SUCCEEDED, + + /** Finished; it will not be retried and the reason is published as a problem document. */ + FAILED, + + /** Stopped on request before it finished. */ + CANCELED, + + /** + * The record aged out before the client read it. + * + *

Distinct from FAILED on purpose: the operation may well have succeeded, and the honest thing + * to tell a client is that the answer is no longer available — not that the work failed. + */ + EXPIRED; + + /** Whether no further state change is possible. */ + public boolean terminal() { + return this == SUCCEEDED || this == FAILED || this == CANCELED || this == EXPIRED; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/pagination/FilterFieldCatalog.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/pagination/FilterFieldCatalog.java new file mode 100644 index 00000000..8563a0c0 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/pagination/FilterFieldCatalog.java @@ -0,0 +1,79 @@ +package dev.caskeleton.adapter.inbound.web.pagination; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** + * Which fields may be filtered, and with which operators. + * + *

Per field rather than globally. A field can be safe to compare for equality and disastrous to + * range-scan: {@code status eq OPEN} uses an index, {@code createdAt gte …} without one reads the + * table, and a single global operator list cannot express the difference. Pairing them here means + * the registration that publishes a field also states what may be done to it. + */ +public final class FilterFieldCatalog { + + private final Map fields; + + private FilterFieldCatalog(Map fields) { + this.fields = Map.copyOf(fields); + } + + /** A catalog over the registered fields. */ + public static FilterFieldCatalog of(FilterableField... fields) { + Objects.requireNonNull(fields, "fields"); + Map byName = new LinkedHashMap<>(); + for (FilterableField field : fields) { + if (byName.putIfAbsent(field.externalName(), field) != null) { + throw new IllegalArgumentException("duplicate filter field: " + field.externalName()); + } + } + return new FilterFieldCatalog(byName); + } + + /** + * The field and operator a client named. + * + * @throws UnsupportedQueryVocabularyException when the field is unpublished or the operator is + * not one that field allows + */ + public FilterableField resolve(String externalName, String operator) { + FilterableField field = externalName == null ? null : fields.get(externalName); + if (field == null) { + throw new UnsupportedQueryVocabularyException("filter field"); + } + if (!field.allowedOperators().contains(operator)) { + throw new UnsupportedQueryVocabularyException("filter operator for this field"); + } + return field; + } + + /** Every published field name. */ + public Set publishedNames() { + return fields.keySet(); + } + + /** + * One filterable field. + * + * @param externalName the name a client sends + * @param internalName the query descriptor the storage layer resolves + * @param allowedOperators the operators this field supports, which is an index decision + */ + public record FilterableField( + String externalName, String internalName, Set allowedOperators) { + + public FilterableField { + Objects.requireNonNull(externalName, "externalName"); + Objects.requireNonNull(internalName, "internalName"); + Objects.requireNonNull(allowedOperators, "allowedOperators"); + if (allowedOperators.isEmpty()) { + throw new IllegalArgumentException( + "a filterable field with no operator cannot be filtered: " + externalName); + } + allowedOperators = Set.copyOf(allowedOperators); + } + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/pagination/FilterFingerprint.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/pagination/FilterFingerprint.java new file mode 100644 index 00000000..1c81d239 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/pagination/FilterFingerprint.java @@ -0,0 +1,70 @@ +package dev.caskeleton.adapter.inbound.web.pagination; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Base64; +import java.util.Map; +import java.util.Objects; +import java.util.TreeMap; + +/** + * A stable digest of the filter and sort a page was produced under. + * + *

What a cursor is bound to. Without it a cursor minted under {@code status=OPEN} can be + * replayed against {@code status=CLOSED} and page through rows the second query was supposed to + * exclude — which, when the filter is a tenant or an owner, is a data leak rather than a paging + * bug. + * + *

Canonical before hashing: keys sorted, separators that cannot occur in a value. Two requests + * that mean the same filter must produce the same fingerprint whatever order the query string + * happened to be in, and two that mean different filters must not collide because a value contained + * the separator. + */ +public final class FilterFingerprint { + + private static final char PAIR_SEPARATOR = '\u001f'; + private static final char KEY_VALUE_SEPARATOR = '\u001e'; + + private FilterFingerprint() {} + + /** + * The fingerprint of one collection query. + * + * @param queryProfile the operation the page belongs to + * @param sort the sort expression, already resolved against the catalog + * @param filters the filter values, already resolved against the catalog + * @param vocabularyFingerprint the catalog's own fingerprint, so a vocabulary change invalidates + */ + public static String of( + String queryProfile, String sort, Map filters, String vocabularyFingerprint) { + Objects.requireNonNull(queryProfile, "queryProfile"); + Objects.requireNonNull(sort, "sort"); + Objects.requireNonNull(filters, "filters"); + Objects.requireNonNull(vocabularyFingerprint, "vocabularyFingerprint"); + StringBuilder canonical = new StringBuilder(); + canonical.append(queryProfile).append(PAIR_SEPARATOR).append(sort); + // A TreeMap so ?a=1&b=2 and ?b=2&a=1 are the same filter, which they are. + new TreeMap<>(filters) + .forEach( + (key, value) -> + canonical + .append(PAIR_SEPARATOR) + .append(key) + .append(KEY_VALUE_SEPARATOR) + .append(value)); + canonical.append(PAIR_SEPARATOR).append(vocabularyFingerprint); + return digest(canonical.toString()); + } + + private static String digest(String canonical) { + try { + MessageDigest sha256 = MessageDigest.getInstance("SHA-256"); + return Base64.getUrlEncoder() + .withoutPadding() + .encodeToString(sha256.digest(canonical.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException impossible) { + throw new IllegalStateException("SHA-256 is required by every JVM", impossible); + } + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/pagination/FilterOperatorCatalog.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/pagination/FilterOperatorCatalog.java new file mode 100644 index 00000000..6db733f4 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/pagination/FilterOperatorCatalog.java @@ -0,0 +1,87 @@ +package dev.caskeleton.adapter.inbound.web.pagination; + +import java.util.LinkedHashSet; +import java.util.Objects; +import java.util.Set; + +/** + * The comparison operators this API publishes. + * + *

An operator is not a value, it is a piece of the query's structure, so an unregistered one is + * a caller writing query logic. The set is also deliberately small: every operator here has an + * index strategy behind it, and adding {@code contains} or {@code regex} without one turns a filter + * into a table scan a caller can trigger at will. + */ +public final class FilterOperatorCatalog { + + /** Equality. */ + public static final String EQUALS = "eq"; + + /** Inequality. */ + public static final String NOT_EQUALS = "ne"; + + /** Strictly greater. */ + public static final String GREATER_THAN = "gt"; + + /** Greater or equal. */ + public static final String GREATER_OR_EQUAL = "gte"; + + /** Strictly less. */ + public static final String LESS_THAN = "lt"; + + /** Less or equal. */ + public static final String LESS_OR_EQUAL = "lte"; + + /** Membership in a bounded set. */ + public static final String IN = "in"; + + /** Prefix match, which an index can serve. */ + public static final String STARTS_WITH = "startsWith"; + + private final Set operators; + + private FilterOperatorCatalog(Set operators) { + this.operators = Set.copyOf(operators); + } + + /** The operators the platform publishes by default. */ + public static FilterOperatorCatalog standard() { + return new FilterOperatorCatalog( + new LinkedHashSet<>( + java.util.List.of( + EQUALS, + NOT_EQUALS, + GREATER_THAN, + GREATER_OR_EQUAL, + LESS_THAN, + LESS_OR_EQUAL, + IN, + STARTS_WITH))); + } + + /** A narrower catalog. */ + public static FilterOperatorCatalog of(Set operators) { + Objects.requireNonNull(operators, "operators"); + if (operators.isEmpty()) { + throw new IllegalArgumentException("a filter catalog with no operator filters nothing"); + } + return new FilterOperatorCatalog(operators); + } + + /** + * The operator a client named. + * + * @throws UnsupportedQueryVocabularyException when it is not published + */ + public String resolve(String operator) { + if (operator == null || !operators.contains(operator)) { + throw new UnsupportedQueryVocabularyException("filter operator"); + } + return operator; + } + + /** Every published operator. */ + public Set published() { + return operators; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/pagination/HmacWebCursorCodec.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/pagination/HmacWebCursorCodec.java new file mode 100644 index 00000000..27eae306 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/pagination/HmacWebCursorCodec.java @@ -0,0 +1,135 @@ +package dev.caskeleton.adapter.inbound.web.pagination; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.Instant; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +/** + * The signed, versioned cursor codec. + * + *

Base64 is encoding, not protection, and treating it as protection is the mistake this class + * exists to prevent: a base64 cursor is a cursor a caller can decode, edit and re-encode, which + * turns "continue where you left off" into "start wherever I say". The MAC is what makes the token + * opaque in the sense that matters. + * + *

The MAC covers the version, the query profile, the filter fingerprint, the position and the + * issue time. Leaving any of them out would let a valid signature be replayed somewhere it does not + * belong — most damagingly the fingerprint, whose absence lets a cursor minted under one filter + * page through a result set the new filter was supposed to exclude. + * + *

Comparison is constant-time. A byte-by-byte MAC check leaks, through timing, how much of a + * forged signature was right, which is enough to construct one given enough attempts. + */ +public final class HmacWebCursorCodec implements WebCursorCodec { + + private static final Base64.Encoder ENCODER = Base64.getUrlEncoder().withoutPadding(); + private static final Base64.Decoder DECODER = Base64.getUrlDecoder(); + private static final char FIELD_SEPARATOR = '.'; + + /** A cursor older than this is refused; a scan that paused for a day is not resumable. */ + private static final java.time.Duration MAX_AGE = java.time.Duration.ofHours(1); + + private final WebCursorKeyRing keyRing; + private final java.time.Clock clock; + + /** + * A codec over a key ring. + * + * @param keyRing the active signing key and the retired verification keys + * @param clock the clock the issue time and expiry are read from + */ + public HmacWebCursorCodec(WebCursorKeyRing keyRing, java.time.Clock clock) { + this.keyRing = Objects.requireNonNull(keyRing, "keyRing"); + this.clock = Objects.requireNonNull(clock, "clock"); + } + + @Override + public String encode(WebCursorPayload payload) { + Objects.requireNonNull(payload, "payload"); + String body = ENCODER.encodeToString(payload.canonicalForm().getBytes(StandardCharsets.UTF_8)); + String keyId = keyRing.activeKeyId(); + String signature = sign(keyRing.activeKey(), keyId + FIELD_SEPARATOR + body); + return keyId + FIELD_SEPARATOR + body + FIELD_SEPARATOR + signature; + } + + @Override + public WebCursorPayload decode( + String cursor, String expectedProfile, String expectedFingerprint) { + Objects.requireNonNull(expectedProfile, "expectedProfile"); + Objects.requireNonNull(expectedFingerprint, "expectedFingerprint"); + if (cursor == null || cursor.isBlank() || cursor.length() > 4096) { + throw new WebCursorException(); + } + String[] parts = cursor.split("\\.", -1); + if (parts.length != 3) { + throw new WebCursorException(); + } + SecretKeySpec key = keyRing.verificationKey(parts[0]).orElseThrow(WebCursorException::new); + String expectedSignature = sign(key, parts[0] + FIELD_SEPARATOR + parts[1]); + if (!MessageDigest.isEqual( + expectedSignature.getBytes(StandardCharsets.UTF_8), + parts[2].getBytes(StandardCharsets.UTF_8))) { + throw new WebCursorException(); + } + WebCursorPayload payload = parse(parts[1]); + if (payload.version() != WebCursorPayload.CURRENT_VERSION) { + throw new WebCursorException(); + } + if (!payload.queryProfile().equals(expectedProfile) + || !payload.filterFingerprint().equals(expectedFingerprint)) { + throw new WebCursorException(); + } + if (payload.issuedAt().plus(MAX_AGE).isBefore(clock.instant())) { + throw new WebCursorException(); + } + return payload; + } + + private WebCursorPayload parse(String encodedBody) { + String canonical; + try { + canonical = new String(DECODER.decode(encodedBody), StandardCharsets.UTF_8); + } catch (IllegalArgumentException notBase64) { + throw new WebCursorException(); + } + String[] fields = canonical.split("\\u001f", -1); + if (fields.length < 4) { + throw new WebCursorException(); + } + Map sortValues = new LinkedHashMap<>(); + for (int index = 3; index < fields.length - 1; index++) { + String[] pair = fields[index].split("\\u001e", -1); + if (pair.length != 2) { + throw new WebCursorException(); + } + sortValues.put(pair[0], pair[1]); + } + try { + return new WebCursorPayload( + Integer.parseInt(fields[0]), + fields[1], + fields[2], + sortValues, + Instant.ofEpochMilli(Long.parseLong(fields[fields.length - 1]))); + } catch (RuntimeException malformed) { + throw new WebCursorException(); + } + } + + private static String sign(SecretKeySpec key, String data) { + try { + Mac mac = Mac.getInstance(WebCursorKeyRing.ALGORITHM); + mac.init(key); + return ENCODER.encodeToString(mac.doFinal(data.getBytes(StandardCharsets.UTF_8))); + } catch (java.security.GeneralSecurityException unusableKey) { + // The ring validated the key at construction, so this is a broken JVM rather than input. + throw new IllegalStateException("cursor signing is unavailable", unusableKey); + } + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/pagination/InMemoryCollectionQueryCatalog.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/pagination/InMemoryCollectionQueryCatalog.java new file mode 100644 index 00000000..697cf50a --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/pagination/InMemoryCollectionQueryCatalog.java @@ -0,0 +1,93 @@ +package dev.caskeleton.adapter.inbound.web.pagination; + +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** + * The three query vocabularies an operation publishes, held together. + * + *

Together because they are checked together and because a keyset cursor is bound to all three: + * a cursor issued for one sort order, filter set and projection cannot be replayed against another, + * or it would page through a different result set than the one it describes. + * + *

The standard instance is deliberately minimal — an {@code id} sort that is a unique + * tie-breaker and nothing else. A default that published more would make the safe-by-default case + * the one where a deployment ships fields it never reviewed. + */ +public final class InMemoryCollectionQueryCatalog { + + private final SortFieldCatalog sortFields; + private final FilterFieldCatalog filterFields; + private final FilterOperatorCatalog filterOperators; + private final ProjectionProfileCatalog projections; + + /** + * A catalog over explicit vocabularies. + * + * @param sortFields the sortable fields + * @param filterFields the filterable fields and their operators + * @param filterOperators the operators this deployment publishes at all + * @param projections the named field sets + */ + public InMemoryCollectionQueryCatalog( + SortFieldCatalog sortFields, + FilterFieldCatalog filterFields, + FilterOperatorCatalog filterOperators, + ProjectionProfileCatalog projections) { + this.sortFields = Objects.requireNonNull(sortFields, "sortFields"); + this.filterFields = Objects.requireNonNull(filterFields, "filterFields"); + this.filterOperators = Objects.requireNonNull(filterOperators, "filterOperators"); + this.projections = Objects.requireNonNull(projections, "projections"); + if (!sortFields.hasUniqueTieBreaker()) { + // Without one, two rows that compare equal on every sort key have no defined order, so a + // keyset page boundary either repeats a row or skips one — silently, and only under load. + throw new IllegalArgumentException( + "a collection query catalog needs at least one unique tie-breaker sort field"); + } + } + + /** The platform's minimal catalog: sort by id, no filters, a default projection. */ + public static InMemoryCollectionQueryCatalog standard() { + return new InMemoryCollectionQueryCatalog( + SortFieldCatalog.of(new SortField("id", "id", true)), + FilterFieldCatalog.of(), + FilterOperatorCatalog.standard(), + ProjectionProfileCatalog.of(Map.of("default", Set.of("id")))); + } + + /** The sortable fields. */ + public SortFieldCatalog sortFields() { + return sortFields; + } + + /** The filterable fields. */ + public FilterFieldCatalog filterFields() { + return filterFields; + } + + /** The published operators. */ + public FilterOperatorCatalog filterOperators() { + return filterOperators; + } + + /** The named projections. */ + public ProjectionProfileCatalog projections() { + return projections; + } + + /** + * A stable fingerprint of this catalog's vocabulary. + * + *

Bound into a cursor so a cursor issued before a field was added or removed is refused rather + * than paging through a result set with a different shape. + */ + public String vocabularyFingerprint() { + return String.join( + "|", + String.join(",", sortFields.publishedNames()), + String.join(",", filterFields.publishedNames()), + String.join(",", filterOperators.published()), + String.join(",", projections.publishedNames())); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/pagination/ProjectionProfileCatalog.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/pagination/ProjectionProfileCatalog.java new file mode 100644 index 00000000..cd71b9d3 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/pagination/ProjectionProfileCatalog.java @@ -0,0 +1,55 @@ +package dev.caskeleton.adapter.inbound.web.pagination; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** + * The named field sets a client may ask a collection to return. + * + *

Named profiles rather than an arbitrary field list. A caller-composed projection is a caller + * deciding which columns the query reads, which defeats covering indexes and — more importantly — + * makes "which fields is this actor allowed to see" a question answered per request rather than per + * profile. A profile is reviewed once and authorised once. + */ +public final class ProjectionProfileCatalog { + + private final Map> profiles; + + private ProjectionProfileCatalog(Map> profiles) { + this.profiles = Map.copyOf(profiles); + } + + /** A catalog over the registered profiles. */ + public static ProjectionProfileCatalog of(Map> profiles) { + Objects.requireNonNull(profiles, "profiles"); + Map> copy = new LinkedHashMap<>(); + profiles.forEach( + (name, fields) -> { + if (fields == null || fields.isEmpty()) { + throw new IllegalArgumentException("projection profile " + name + " selects nothing"); + } + copy.put(name, Set.copyOf(fields)); + }); + return new ProjectionProfileCatalog(copy); + } + + /** + * The fields a profile selects. + * + * @throws UnsupportedQueryVocabularyException when the profile is not published + */ + public Set resolve(String profileName) { + Set fields = profileName == null ? null : profiles.get(profileName); + if (fields == null) { + throw new UnsupportedQueryVocabularyException("projection profile"); + } + return fields; + } + + /** Every published profile name. */ + public Set publishedNames() { + return profiles.keySet(); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/pagination/SortField.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/pagination/SortField.java new file mode 100644 index 00000000..3e56c25c --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/pagination/SortField.java @@ -0,0 +1,29 @@ +package dev.caskeleton.adapter.inbound.web.pagination; + +import java.util.Objects; + +/** + * A sortable field, as the API names it and as the storage layer names it. + * + *

Two names on purpose. The external one is a published contract that must survive a schema + * rename; the internal one is a query descriptor the storage layer understands. Keeping them in one + * value is what lets the pair be reviewed together — a mapping split across two files drifts, and + * the drift shows up as a 500 on a sort nobody tested. + * + *

The internal name never reaches a query as caller-supplied text: it is chosen from this + * catalog, so the only strings that can be sorted by are ones somebody registered. + * + * @param externalName the name a client sends + * @param internalName the query descriptor the storage layer resolves + * @param uniqueTieBreaker whether this field alone orders the result set deterministically + */ +public record SortField(String externalName, String internalName, boolean uniqueTieBreaker) { + + public SortField { + Objects.requireNonNull(externalName, "externalName"); + Objects.requireNonNull(internalName, "internalName"); + if (externalName.isBlank() || internalName.isBlank()) { + throw new IllegalArgumentException("a sort field needs both names"); + } + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/pagination/SortFieldCatalog.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/pagination/SortFieldCatalog.java new file mode 100644 index 00000000..bb559644 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/pagination/SortFieldCatalog.java @@ -0,0 +1,66 @@ +package dev.caskeleton.adapter.inbound.web.pagination; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** + * The sort fields this API publishes. + * + *

An allowlist, and the reason is the oldest one in the book: a sort name that reaches a query + * is a fragment of that query. Passing {@code ?sort=} straight through to an ORDER BY, a JPQL + * string or a Mongo field path lets a caller order by a column the API never exposed, and in the + * string-concatenation case lets them write the clause. + * + *

Registering a field is therefore how it becomes sortable, and the internal name is chosen by + * the registration rather than derived from the request. + */ +public final class SortFieldCatalog { + + private final Map fields; + + private SortFieldCatalog(Map fields) { + this.fields = Map.copyOf(fields); + } + + /** A catalog over the registered fields. */ + public static SortFieldCatalog of(SortField... fields) { + Objects.requireNonNull(fields, "fields"); + Map byName = new LinkedHashMap<>(); + for (SortField field : fields) { + if (byName.putIfAbsent(field.externalName(), field) != null) { + throw new IllegalArgumentException("duplicate sort field: " + field.externalName()); + } + } + return new SortFieldCatalog(byName); + } + + /** + * The field a client named. + * + * @throws UnsupportedQueryVocabularyException when it is not published + */ + public SortField resolve(String externalName) { + SortField field = externalName == null ? null : fields.get(externalName); + if (field == null) { + throw new UnsupportedQueryVocabularyException("sort field"); + } + return field; + } + + /** Whether a name is published. */ + public boolean published(String externalName) { + return externalName != null && fields.containsKey(externalName); + } + + /** Every published name, for the OpenAPI document. */ + public Set publishedNames() { + return fields.keySet(); + } + + /** Whether any published field can order the result set deterministically on its own. */ + public boolean hasUniqueTieBreaker() { + return fields.values().stream().anyMatch(SortField::uniqueTieBreaker); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/pagination/UnsupportedQueryVocabularyException.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/pagination/UnsupportedQueryVocabularyException.java new file mode 100644 index 00000000..34d281c8 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/pagination/UnsupportedQueryVocabularyException.java @@ -0,0 +1,30 @@ +package dev.caskeleton.adapter.inbound.web.pagination; + +/** + * A request used a sort field, filter operator or projection the API does not publish. + * + *

Its own type so the error translator answers all of them with one 400, and so the message can + * be built in one place that knows not to echo the rejected value. A message that quotes {@code + * drop_table; --} back to the caller is a reflected-content vector in an error body. + */ +public final class UnsupportedQueryVocabularyException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + private final String vocabulary; + + /** + * Creates the failure. + * + * @param vocabulary which vocabulary was violated, for the problem detail + */ + public UnsupportedQueryVocabularyException(String vocabulary) { + super("unsupported " + vocabulary + "; only registered values are accepted"); + this.vocabulary = vocabulary; + } + + /** Which vocabulary was violated. */ + public String vocabulary() { + return vocabulary; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/pagination/WebCollectionRequest.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/pagination/WebCollectionRequest.java new file mode 100644 index 00000000..b220a509 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/pagination/WebCollectionRequest.java @@ -0,0 +1,55 @@ +package dev.caskeleton.adapter.inbound.web.pagination; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +/** + * One collection request, after every value has been checked against the catalogs. + * + *

Nothing here is caller text. The sort is a resolved {@link SortField}, the filters are keyed + * by resolved field names, and the cursor has already been verified against this exact query. That + * is the contract with the storage layer: what it receives cannot contain a fragment of a query, + * because none of it survived the parser as a string the caller chose. + * + *

{@code totalCount} is opt-in. Counting is a second query over the whole filtered set, so a + * platform that computes it by default makes every list endpoint twice as expensive to serve a + * number most clients render and ignore. + * + * @param queryProfile the operation this request belongs to + * @param sort the resolved sort field + * @param descending whether the scan runs backwards + * @param filters resolved internal field name to value + * @param pageSize how many rows this page may return + * @param cursor the verified continuation position, empty for the first page + * @param filterFingerprint the digest a continuation cursor is bound to + * @param includeTotalCount whether the caller asked for, and the profile permits, a total + */ +public record WebCollectionRequest( + String queryProfile, + SortField sort, + boolean descending, + Map filters, + int pageSize, + Optional cursor, + String filterFingerprint, + boolean includeTotalCount) { + + public WebCollectionRequest { + Objects.requireNonNull(queryProfile, "queryProfile"); + Objects.requireNonNull(sort, "sort"); + Objects.requireNonNull(filters, "filters"); + Objects.requireNonNull(cursor, "cursor"); + Objects.requireNonNull(filterFingerprint, "filterFingerprint"); + if (pageSize < 1) { + throw new IllegalArgumentException("page size must be positive"); + } + filters = Map.copyOf(new LinkedHashMap<>(filters)); + } + + /** Whether this is a continuation rather than a first page. */ + public boolean continuation() { + return cursor.isPresent(); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/pagination/WebCollectionRequestParser.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/pagination/WebCollectionRequestParser.java new file mode 100644 index 00000000..2d1bf71b --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/pagination/WebCollectionRequestParser.java @@ -0,0 +1,135 @@ +package dev.caskeleton.adapter.inbound.web.pagination; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +/** + * Turns query parameters into a checked collection request. + * + *

The order of the checks matters and is the reason this is one class rather than a chain of + * small ones. The fingerprint is computed from the resolved sort and filters, and the cursor is + * then verified against that fingerprint — so a cursor cannot be validated before the platform + * knows which query it is being replayed into. Doing it the other way round is how a cursor from + * one filter gets accepted under another. + * + *

Every failure is a stable exception the error translator maps to a 400. None of them echo the + * rejected value. + */ +public final class WebCollectionRequestParser { + + private final InMemoryCollectionQueryCatalog catalog; + private final WebPageSizePolicy pageSizePolicy; + private final WebCursorCodec cursorCodec; + + /** + * A parser for one deployment's vocabulary. + * + * @param catalog the published sort, filter and projection vocabulary + * @param pageSizePolicy the page bounds + * @param cursorCodec the signed cursor codec + */ + public WebCollectionRequestParser( + InMemoryCollectionQueryCatalog catalog, + WebPageSizePolicy pageSizePolicy, + WebCursorCodec cursorCodec) { + this.catalog = Objects.requireNonNull(catalog, "catalog"); + this.pageSizePolicy = Objects.requireNonNull(pageSizePolicy, "pageSizePolicy"); + this.cursorCodec = Objects.requireNonNull(cursorCodec, "cursorCodec"); + } + + /** + * Parses one collection request. + * + * @param queryProfile the operation being served + * @param sortParameter the requested sort, {@code -name} for descending, null for the default + * @param rawFilters the requested filters, keyed by external field name + * @param requestedPageSize the requested page size, null for the default + * @param rawCursor the continuation cursor, null for the first page + * @param requestTotalCount whether the caller asked for a total + * @param totalCountPermitted whether the operation profile permits one + */ + public WebCollectionRequest parse( + String queryProfile, + String sortParameter, + Map rawFilters, + Integer requestedPageSize, + String rawCursor, + boolean requestTotalCount, + boolean totalCountPermitted) { + Objects.requireNonNull(queryProfile, "queryProfile"); + Map filters = rawFilters == null ? Map.of() : rawFilters; + + boolean descending = sortParameter != null && sortParameter.startsWith("-"); + String sortName = sortParameter == null ? defaultSortName() : stripDirection(sortParameter); + SortField sort = catalog.sortFields().resolve(sortName); + + Map resolvedFilters = new LinkedHashMap<>(); + filters.forEach( + (name, value) -> { + // Every filter arrives as field and operator; the operator decides what the storage layer + // is allowed to do with the field, so both are resolved before the value is kept. + String[] parts = name.split("\\.", 2); + String field = parts[0]; + String operator = parts.length == 2 ? parts[1] : FilterOperatorCatalog.EQUALS; + catalog.filterOperators().resolve(operator); + var resolved = catalog.filterFields().resolve(field, operator); + resolvedFilters.put(resolved.internalName() + "." + operator, value); + }); + + int pageSize = pageSizePolicy.resolve(requestedPageSize); + String fingerprint = + FilterFingerprint.of( + queryProfile, + (descending ? "-" : "") + sort.internalName(), + resolvedFilters, + catalog.vocabularyFingerprint()); + + Optional cursor = + rawCursor == null || rawCursor.isBlank() + ? Optional.empty() + : Optional.of(cursorCodec.decode(rawCursor, queryProfile, fingerprint)); + + if (requestTotalCount && !totalCountPermitted) { + throw new UnsupportedQueryVocabularyException("total count profile"); + } + + return new WebCollectionRequest( + queryProfile, + sort, + descending, + resolvedFilters, + pageSize, + cursor, + fingerprint, + requestTotalCount && totalCountPermitted); + } + + /** + * The cursor for the next page of a request. + * + * @param request the request the page answered + * @param lastRowSortValues the sort key values of the last row, ending with the tie-breaker + * @param issuedAt when the cursor is minted + */ + public String nextCursor( + WebCollectionRequest request, + Map lastRowSortValues, + java.time.Instant issuedAt) { + Objects.requireNonNull(request, "request"); + return cursorCodec.encode( + WebCursorPayload.current( + request.queryProfile(), request.filterFingerprint(), lastRowSortValues, issuedAt)); + } + + private String defaultSortName() { + return catalog.sortFields().publishedNames().iterator().next(); + } + + private static String stripDirection(String sortParameter) { + return sortParameter.startsWith("-") || sortParameter.startsWith("+") + ? sortParameter.substring(1) + : sortParameter; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/pagination/WebCursorCodec.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/pagination/WebCursorCodec.java new file mode 100644 index 00000000..8bfea2f9 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/pagination/WebCursorCodec.java @@ -0,0 +1,29 @@ +package dev.caskeleton.adapter.inbound.web.pagination; + +/** + * Turns a cursor position into an opaque token and back. + * + *

An interface so a deployment can substitute a different signing scheme without the collection + * parser knowing. What it may not substitute is the check: {@link #decode} takes the query profile + * and filter fingerprint it expects, so an implementation cannot quietly stop verifying that the + * cursor belongs to this query. + */ +public interface WebCursorCodec { + + /** + * Mints an opaque cursor. + * + * @param payload where the page stopped and which query it stopped in + */ + String encode(WebCursorPayload payload); + + /** + * Reads a cursor, refusing one that does not belong to this query. + * + * @param cursor the token a client sent back + * @param expectedProfile the query profile the current request belongs to + * @param expectedFingerprint the filter and vocabulary fingerprint of the current request + * @throws WebCursorException when the cursor is malformed, unsigned, or from another query + */ + WebCursorPayload decode(String cursor, String expectedProfile, String expectedFingerprint); +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/pagination/WebCursorException.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/pagination/WebCursorException.java new file mode 100644 index 00000000..43e9006f --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/pagination/WebCursorException.java @@ -0,0 +1,19 @@ +package dev.caskeleton.adapter.inbound.web.pagination; + +/** + * A cursor could not be trusted. + * + *

One exception for every reason — bad encoding, unknown version, wrong profile, changed filter, + * failed MAC — and deliberately so. Telling a caller which check failed tells them how to get + * closer: "bad MAC" says the structure parsed, "unknown version" says the signature verified. The + * only useful answer is that the cursor is not valid here. + */ +public final class WebCursorException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + /** Creates the failure. */ + public WebCursorException() { + super("the cursor is not valid for this query"); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/pagination/WebCursorKeyRing.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/pagination/WebCursorKeyRing.java new file mode 100644 index 00000000..afb1197b --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/pagination/WebCursorKeyRing.java @@ -0,0 +1,77 @@ +package dev.caskeleton.adapter.inbound.web.pagination; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import javax.crypto.spec.SecretKeySpec; + +/** + * The signing keys, with one active for minting and the rest kept for verification. + * + *

A ring rather than a key because rotation is otherwise a breaking change: every cursor a + * client is holding was signed with the old key, and replacing the key outright turns all of them + * into refusals mid-scan. Keeping retired keys verifiable lets a rotation happen without the API + * appearing to break. + * + *

There is no default key. A hard-coded signing secret is a signature anybody who has read the + * source can forge, and a "development default" is the one that ships. + */ +public final class WebCursorKeyRing { + + /** HMAC-SHA256: the MAC needs to be unforgeable, not fast. */ + public static final String ALGORITHM = "HmacSHA256"; + + private final String activeKeyId; + private final Map keys; + + private WebCursorKeyRing(String activeKeyId, Map keys) { + this.activeKeyId = activeKeyId; + this.keys = Map.copyOf(keys); + } + + /** + * A ring with one active key and any number of retired ones. + * + * @param activeKeyId which key new cursors are signed with + * @param keysById every key that may verify, including the active one + */ + public static WebCursorKeyRing of(String activeKeyId, Map keysById) { + Objects.requireNonNull(activeKeyId, "activeKeyId"); + Objects.requireNonNull(keysById, "keysById"); + if (!keysById.containsKey(activeKeyId)) { + throw new IllegalArgumentException("the active key must be in the ring: " + activeKeyId); + } + Map specs = new LinkedHashMap<>(); + keysById.forEach( + (id, material) -> { + Objects.requireNonNull(material, "key material for " + id); + if (material.length < 32) { + throw new IllegalArgumentException( + "cursor signing key " + id + " is shorter than the 256-bit MAC it produces"); + } + specs.put(id, new SecretKeySpec(material.clone(), ALGORITHM)); + }); + return new WebCursorKeyRing(activeKeyId, specs); + } + + /** The key new cursors are signed with. */ + public String activeKeyId() { + return activeKeyId; + } + + /** The active signing key. */ + public SecretKeySpec activeKey() { + return keys.get(activeKeyId); + } + + /** A key by id, when the ring still holds it. */ + public Optional verificationKey(String keyId) { + return keyId == null ? Optional.empty() : Optional.ofNullable(keys.get(keyId)); + } + + /** How many keys can verify. */ + public int size() { + return keys.size(); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/pagination/WebCursorPayload.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/pagination/WebCursorPayload.java new file mode 100644 index 00000000..03442249 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/pagination/WebCursorPayload.java @@ -0,0 +1,79 @@ +package dev.caskeleton.adapter.inbound.web.pagination; + +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + +/** + * What a cursor actually says: where the last page stopped, and what query it stopped in. + * + *

The position alone is not enough, and that is the whole reason this record has five + * components. A cursor that only carried sort values could be replayed against a different sort + * order, a different filter, or a different projection — and each of those pages through a + * different result set than the cursor describes. Under a changed filter, that is a caller reading + * rows the new query was scoped away from. + * + *

So the query profile and a filter fingerprint are authenticated alongside the position, and + * decoding checks all of them. + * + * @param version the cursor format version, so a format change is a refusal rather than a misparse + * @param queryProfile which registered query this cursor belongs to + * @param filterFingerprint a digest of the filter and vocabulary the page was produced under + * @param sortValues the last row's sort key values, in sort order, ending with the tie-breaker + * @param issuedAt when the cursor was minted + */ +public record WebCursorPayload( + int version, + String queryProfile, + String filterFingerprint, + Map sortValues, + Instant issuedAt) { + + /** The only format this platform mints. */ + public static final int CURRENT_VERSION = 1; + + public WebCursorPayload { + Objects.requireNonNull(queryProfile, "queryProfile"); + Objects.requireNonNull(filterFingerprint, "filterFingerprint"); + Objects.requireNonNull(sortValues, "sortValues"); + Objects.requireNonNull(issuedAt, "issuedAt"); + if (version < 1) { + throw new IllegalArgumentException("cursor version must be positive"); + } + if (queryProfile.isBlank()) { + throw new IllegalArgumentException("a cursor belongs to a named query profile"); + } + if (sortValues.isEmpty()) { + throw new IllegalArgumentException("a cursor with no position cannot continue a scan"); + } + // LinkedHashMap: the order is the sort order, and a cursor whose keys reorder describes a + // different position. + sortValues = java.util.Collections.unmodifiableMap(new LinkedHashMap<>(sortValues)); + } + + /** A cursor in the current format. */ + public static WebCursorPayload current( + String queryProfile, + String filterFingerprint, + Map sortValues, + Instant issuedAt) { + return new WebCursorPayload( + CURRENT_VERSION, queryProfile, filterFingerprint, sortValues, issuedAt); + } + + /** The canonical bytes the MAC is computed over. */ + public String canonicalForm() { + StringBuilder canonical = new StringBuilder(); + canonical + .append(version) + .append('\u001f') + .append(queryProfile) + .append('\u001f') + .append(filterFingerprint); + sortValues.forEach( + (key, value) -> canonical.append('\u001f').append(key).append('\u001e').append(value)); + canonical.append('\u001f').append(issuedAt.toEpochMilli()); + return canonical.toString(); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/pagination/WebPageSizePolicy.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/pagination/WebPageSizePolicy.java new file mode 100644 index 00000000..46c0f5b9 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/pagination/WebPageSizePolicy.java @@ -0,0 +1,61 @@ +package dev.caskeleton.adapter.inbound.web.pagination; + +/** + * How many rows one page may return. + * + *

A hard maximum rather than a default a caller can raise. {@code ?limit=1000000} is the + * cheapest denial of service an API offers: one request, one query, and the response is assembled + * in memory before a byte is written. Clamping silently would be friendlier and worse — a client + * that asked for 1000 and got 200 pages incorrectly, because it believes the short page is the last + * one. + * + * @param defaultSize the page size a request that names none gets + * @param maximumSize the largest page any request may ask for + */ +public record WebPageSizePolicy(int defaultSize, int maximumSize) { + + /** The platform's Standard profile. */ + public static final int STANDARD_DEFAULT = 50; + + /** The platform's hard ceiling. */ + public static final int STANDARD_MAXIMUM = 200; + + public WebPageSizePolicy { + if (defaultSize < 1) { + throw new IllegalArgumentException("default page size must be positive"); + } + if (maximumSize < defaultSize) { + throw new IllegalArgumentException("the maximum page size cannot be below the default"); + } + if (maximumSize > STANDARD_MAXIMUM) { + throw new IllegalArgumentException( + "page maximum exceeds the platform ceiling of " + STANDARD_MAXIMUM); + } + } + + /** The Standard profile: 50 by default, never more than 200. */ + public static WebPageSizePolicy standard() { + return new WebPageSizePolicy(STANDARD_DEFAULT, STANDARD_MAXIMUM); + } + + /** + * The size for a requested limit. + * + * @param requested what the client asked for, null when it asked for nothing + * @throws IllegalArgumentException when the request is above the maximum or not positive + */ + public int resolve(Integer requested) { + if (requested == null) { + return defaultSize; + } + if (requested < 1) { + throw new IllegalArgumentException("page size must be positive"); + } + if (requested > maximumSize) { + // Refused rather than clamped: a client that asked for 1000 and silently got 200 believes the + // short page is the last one and stops paging. + throw new IllegalArgumentException("page size exceeds the maximum of " + maximumSize); + } + return requested; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/proxy/ForwardedHeaderSanitizer.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/proxy/ForwardedHeaderSanitizer.java new file mode 100644 index 00000000..b9f52ddf --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/proxy/ForwardedHeaderSanitizer.java @@ -0,0 +1,72 @@ +package dev.caskeleton.adapter.inbound.web.proxy; + +import java.net.InetAddress; +import java.util.Map; +import java.util.Objects; + +/** + * Decides whether a request's forwarded headers may be believed, and normalises them if so. + * + *

The topology this assumes is the one the design states: a trusted proxy strips whatever + * forwarded headers a client sent and sets authoritative ones itself. That assumption is what makes + * "trusted peer" sufficient — the peer is trusted to have overwritten, not merely to have appended, + * so the value cannot be a client's with a proxy's added on top. + * + *

A direct-access deployment configures {@link TrustedProxyPolicy#trustNobody()} and gets {@link + * NormalizedForwardedHeaders#none()} for every request, including ones that carry the headers. The + * server's own socket is then the only source, which is correct when nothing is in front of it. + */ +public final class ForwardedHeaderSanitizer { + + private final TrustedProxyPolicy trustedProxyPolicy; + private final boolean refuseUntrusted; + + /** + * A sanitiser that refuses forwarded headers from an untrusted peer. + * + * @param trustedProxyPolicy which peers may set them + */ + public ForwardedHeaderSanitizer(TrustedProxyPolicy trustedProxyPolicy) { + this(trustedProxyPolicy, true); + } + + /** + * A sanitiser with an explicit decision about untrusted peers. + * + * @param trustedProxyPolicy which peers may set them + * @param refuseUntrusted true to fail the request, false to ignore the headers silently + */ + public ForwardedHeaderSanitizer(TrustedProxyPolicy trustedProxyPolicy, boolean refuseUntrusted) { + this.trustedProxyPolicy = Objects.requireNonNull(trustedProxyPolicy, "trustedProxyPolicy"); + this.refuseUntrusted = refuseUntrusted; + } + + /** + * Normalises the forwarded headers of one request. + * + * @param peerAddress the address the connection actually came from + * @param headers the request headers + * @return the trusted external view, or {@link NormalizedForwardedHeaders#none()} + * @throws UntrustedForwardedHeaderException when an untrusted peer sent forwarded headers and + * this sanitiser is configured to refuse them + */ + public NormalizedForwardedHeaders normalize(String peerAddress, Map headers) { + Objects.requireNonNull(headers, "headers"); + boolean forwarded = NormalizedForwardedHeaders.present(headers); + boolean trusted = trustedProxyPolicy.isTrusted(peerAddress); + if (forwarded && !trusted) { + if (refuseUntrusted) { + throw new UntrustedForwardedHeaderException(String.valueOf(peerAddress)); + } + return NormalizedForwardedHeaders.none(); + } + return trusted + ? NormalizedForwardedHeaders.fromTrustedHeaders(headers) + : NormalizedForwardedHeaders.none(); + } + + /** Normalises using a resolved peer address. */ + public NormalizedForwardedHeaders normalize(InetAddress peer, Map headers) { + return normalize(peer == null ? null : peer.getHostAddress(), headers); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/proxy/NormalizedForwardedHeaders.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/proxy/NormalizedForwardedHeaders.java new file mode 100644 index 00000000..086d1b88 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/proxy/NormalizedForwardedHeaders.java @@ -0,0 +1,158 @@ +package dev.caskeleton.adapter.inbound.web.proxy; + +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +/** + * What a trusted proxy said the request looked like from outside, after normalisation. + * + *

Only four facts survive: scheme, host, port and prefix. Everything else a forwarded header can + * carry — the original path, a query string, a chain of intermediaries — is discarded, because this + * value is consumed to build absolute URLs and anything kept here becomes something a caller can + * influence in a {@code Location} header. + * + *

The client address is separate and deliberately not part of URL construction: it is used for + * rate limiting and audit, where a wrong value has a different consequence than a wrong link. + * + * @param scheme the external scheme, lower case + * @param host the external host, lower case, without a port + * @param port the external port + * @param prefix the external path prefix, empty or beginning with a slash + * @param clientAddress the address the proxy says the request came from + */ +public record NormalizedForwardedHeaders( + Optional scheme, + Optional host, + Optional port, + Optional prefix, + Optional clientAddress) { + + /** The standard forwarded header names, in the spellings a proxy actually sets. */ + public static final String X_FORWARDED_PROTO = "X-Forwarded-Proto"; + + /** The external host. */ + public static final String X_FORWARDED_HOST = "X-Forwarded-Host"; + + /** The external port. */ + public static final String X_FORWARDED_PORT = "X-Forwarded-Port"; + + /** The external path prefix. */ + public static final String X_FORWARDED_PREFIX = "X-Forwarded-Prefix"; + + /** The originating client. */ + public static final String X_FORWARDED_FOR = "X-Forwarded-For"; + + /** RFC 7239's single header. */ + public static final String FORWARDED = "Forwarded"; + + public NormalizedForwardedHeaders { + Objects.requireNonNull(scheme, "scheme"); + Objects.requireNonNull(host, "host"); + Objects.requireNonNull(port, "port"); + Objects.requireNonNull(prefix, "prefix"); + Objects.requireNonNull(clientAddress, "clientAddress"); + } + + /** Nothing was forwarded; the server's own view of the request is authoritative. */ + public static NormalizedForwardedHeaders none() { + return new NormalizedForwardedHeaders( + Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty(), Optional.empty()); + } + + /** + * Reads the headers a trusted proxy set. + * + *

Only called after the peer has been established as trusted. Every value is validated anyway: + * a trusted proxy is trusted to be honest, not to be correct, and a misconfigured Nginx + * forwarding an empty host would otherwise produce {@code https:///reset}. + * + * @param headers the request headers, keyed case-insensitively by the caller + */ + public static NormalizedForwardedHeaders fromTrustedHeaders(Map headers) { + Objects.requireNonNull(headers, "headers"); + return new NormalizedForwardedHeaders( + first(headers, X_FORWARDED_PROTO) + .map(value -> value.toLowerCase(Locale.ROOT)) + .filter(value -> value.equals("http") || value.equals("https")), + first(headers, X_FORWARDED_HOST) + .map(value -> value.toLowerCase(Locale.ROOT)) + .map(NormalizedForwardedHeaders::hostWithoutPort) + .filter(NormalizedForwardedHeaders::plausibleHost), + first(headers, X_FORWARDED_PORT).flatMap(NormalizedForwardedHeaders::parsePort), + first(headers, X_FORWARDED_PREFIX).flatMap(NormalizedForwardedHeaders::normalizePrefix), + first(headers, X_FORWARDED_FOR).map(NormalizedForwardedHeaders::firstHop)); + } + + /** Whether a header map carries any forwarded header at all. */ + public static boolean present(Map headers) { + if (headers == null) { + return false; + } + return headers.keySet().stream() + .map(name -> name.toLowerCase(Locale.ROOT)) + .anyMatch(name -> name.equals("forwarded") || name.startsWith("x-forwarded-")); + } + + private static Optional first(Map headers, String name) { + return headers.entrySet().stream() + .filter(entry -> entry.getKey().equalsIgnoreCase(name)) + .map(Map.Entry::getValue) + .filter(value -> value != null && !value.isBlank()) + .map(String::trim) + .findFirst(); + } + + /** + * The first hop of a comma-separated chain. + * + *

The first, not the last. A proxy appends, so the leftmost entry is the originating client — + * and it is also the one a client can forge, which is why this is only read from a trusted peer + * that has been configured to overwrite rather than append. + */ + private static String firstHop(String value) { + int comma = value.indexOf(','); + return (comma < 0 ? value : value.substring(0, comma)).trim(); + } + + private static String hostWithoutPort(String value) { + if (value.startsWith("[")) { + int close = value.indexOf(']'); + return close > 0 ? value.substring(0, close + 1) : value; + } + int colon = value.indexOf(':'); + return colon < 0 ? value : value.substring(0, colon); + } + + private static boolean plausibleHost(String value) { + return !value.isBlank() + && value.length() <= 253 + && value + .chars() + .noneMatch(character -> character <= 0x20 || character == '/' || character == '\\'); + } + + private static Optional parsePort(String value) { + try { + int port = Integer.parseInt(value); + return port >= 1 && port <= 65535 ? Optional.of(port) : Optional.empty(); + } catch (NumberFormatException notANumber) { + return Optional.empty(); + } + } + + private static Optional normalizePrefix(String value) { + String prefix = value.trim(); + while (prefix.endsWith("/")) { + prefix = prefix.substring(0, prefix.length() - 1); + } + if (prefix.isEmpty()) { + return Optional.empty(); + } + if (!prefix.startsWith("/") || prefix.contains("..") || prefix.contains("//")) { + return Optional.empty(); + } + return Optional.of(prefix); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/proxy/TrustedProxyPolicy.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/proxy/TrustedProxyPolicy.java new file mode 100644 index 00000000..f39d3d99 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/proxy/TrustedProxyPolicy.java @@ -0,0 +1,161 @@ +package dev.caskeleton.adapter.inbound.web.proxy; + +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; + +/** + * Which peers are allowed to tell this service what the request looked like from outside. + * + *

An allowlist of CIDR ranges, and empty by default. Forwarded headers are the only way a + * service behind a proxy can know its own external scheme, host and client address — and they are + * plain request headers, so any caller that can reach the port can set them. A deployment that + * trusts them from everybody lets a client choose the IP its rate limit is keyed on, the host its + * password-reset link points at, and whether the request "arrived over HTTPS". + * + *

Empty means trust nobody, which is the safe direction: a direct-access deployment reads its + * own socket and is correct, while a proxied deployment that forgot to configure this gets wrong + * URLs rather than a forgeable trust boundary. + */ +public final class TrustedProxyPolicy { + + private final List ranges; + + private TrustedProxyPolicy(List ranges) { + this.ranges = List.copyOf(ranges); + } + + /** Trusts nobody; forwarded headers are ignored wherever they came from. */ + public static TrustedProxyPolicy trustNobody() { + return new TrustedProxyPolicy(List.of()); + } + + /** + * Trusts the given CIDR ranges. + * + * @param cidrs ranges in {@code a.b.c.d/len} form + */ + public static TrustedProxyPolicy of(String... cidrs) { + Objects.requireNonNull(cidrs, "cidrs"); + List parsed = new ArrayList<>(); + for (String cidr : cidrs) { + parsed.add(CidrRange.parse(cidr)); + } + return new TrustedProxyPolicy(parsed); + } + + /** Whether a peer may set forwarded headers. */ + public boolean isTrusted(InetAddress peer) { + if (peer == null || ranges.isEmpty()) { + return false; + } + return ranges.stream().anyMatch(range -> range.contains(peer)); + } + + /** Whether a peer address may set forwarded headers. */ + public boolean isTrusted(String peerAddress) { + if (peerAddress == null || peerAddress.isBlank()) { + return false; + } + try { + // Parsed as a literal only. Resolving a name here would let a caller point a DNS record at a + // trusted range and be believed. + return isTrusted(InetAddress.getByName(peerAddress.trim())); + } catch (UnknownHostException unresolvable) { + return false; + } + } + + /** Whether this policy trusts anybody at all. */ + public boolean trustsAnybody() { + return !ranges.isEmpty(); + } + + /** + * One CIDR range, matched on raw address bytes so IPv4 and IPv6 use the same code. + * + *

A class rather than a record: the network is a byte array, and a record component that is an + * array gets identity equality and a useless {@code toString}, which is exactly the trap + * ErrorProne's {@code ArrayRecordComponent} names. The array is defensively copied on the way in + * and never handed back out, so this one is genuinely immutable. + */ + private static final class CidrRange { + + private final byte[] network; + private final int prefixLength; + + private CidrRange(byte[] network, int prefixLength) { + this.network = network.clone(); + this.prefixLength = prefixLength; + } + + static CidrRange parse(String cidr) { + Objects.requireNonNull(cidr, "cidr"); + int slash = cidr.indexOf('/'); + if (slash < 0) { + throw new IllegalArgumentException("a trusted proxy range needs a prefix length: " + cidr); + } + String address = cidr.substring(0, slash).trim(); + int prefixLength; + try { + prefixLength = Integer.parseInt(cidr.substring(slash + 1).trim()); + } catch (NumberFormatException notANumber) { + throw new IllegalArgumentException("invalid prefix length in " + cidr, notANumber); + } + byte[] network; + try { + network = InetAddress.getByName(address).getAddress(); + } catch (UnknownHostException unresolvable) { + throw new IllegalArgumentException("invalid address in " + cidr, unresolvable); + } + if (prefixLength < 0 || prefixLength > network.length * 8) { + throw new IllegalArgumentException("prefix length out of range in " + cidr); + } + return new CidrRange(network, prefixLength); + } + + boolean contains(InetAddress candidate) { + byte[] address = candidate.getAddress(); + if (address.length != network.length) { + // An IPv4 peer never matches an IPv6 range and vice versa: the byte lengths differ, so the + // comparison is not even attempted. Note that this is not the same as refusing IPv4-mapped + // forms — the JDK canonicalises ::ffff:10.0.0.1 to an Inet4Address before it reaches here, + // so a mapped trusted address stays trusted. That is correct: the peer address comes from + // the socket, not from a header, so a caller cannot choose which form it arrives in. + return false; + } + int fullBytes = prefixLength / 8; + for (int index = 0; index < fullBytes; index++) { + if (address[index] != network[index]) { + return false; + } + } + int remainingBits = prefixLength % 8; + if (remainingBits == 0) { + return true; + } + int mask = 0xFF << (8 - remainingBits); + return (address[fullBytes] & mask) == (network[fullBytes] & mask); + } + + @Override + public boolean equals(Object other) { + return other instanceof CidrRange range + && prefixLength == range.prefixLength + && Arrays.equals(network, range.network); + } + + @Override + public int hashCode() { + return Arrays.hashCode(network) * 31 + prefixLength; + } + + @Override + public String toString() { + return prefixLength + "-bit range"; + } + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/proxy/UntrustedForwardedHeaderException.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/proxy/UntrustedForwardedHeaderException.java new file mode 100644 index 00000000..e1b3b88b --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/proxy/UntrustedForwardedHeaderException.java @@ -0,0 +1,30 @@ +package dev.caskeleton.adapter.inbound.web.proxy; + +/** + * A peer that is not a configured proxy sent a forwarded header. + * + *

Refused rather than ignored, and the difference matters. Ignoring means the request is served + * with the server's own view, which is usually right — so the attempt leaves no trace and the same + * client keeps probing. Refusing makes the attempt a visible 400 and a log line, which is what an + * operator needs to see that somebody is trying to choose their own client address. + * + *

The message names the peer and nothing else. Echoing the header value would put + * attacker-chosen content into the log line that reports the attack. + */ +public final class UntrustedForwardedHeaderException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + /** + * Creates the failure. + * + * @param peerAddress the peer that sent the header + */ + public UntrustedForwardedHeaderException(String peerAddress) { + super( + "forwarded headers were sent by " + + peerAddress + + ", which is not a configured trusted proxy; believing them would let a caller choose" + + " its own client address, host and scheme"); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimitDecision.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimitDecision.java new file mode 100644 index 00000000..30294e19 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimitDecision.java @@ -0,0 +1,65 @@ +package dev.caskeleton.adapter.inbound.web.ratelimit; + +import java.time.Duration; +import java.time.Instant; +import java.util.Objects; +import java.util.Optional; + +/** + * What a limiter decided about one request. + * + *

Carries the window state as well as the verdict, because a caller that is refused needs to + * know when to come back and a caller that is allowed needs to know how close it is. A bare boolean + * produces clients that either retry immediately in a loop or back off far longer than necessary, + * and both are worse for the service than telling them. + * + * @param allowed whether the request may proceed + * @param limit the quota for the window + * @param remaining how much of it is left + * @param resetAt when the window rolls over + * @param retryAfter how long to wait, present only when refused + */ +public record RateLimitDecision( + boolean allowed, long limit, long remaining, Instant resetAt, Optional retryAfter) { + + public RateLimitDecision { + Objects.requireNonNull(resetAt, "resetAt"); + Objects.requireNonNull(retryAfter, "retryAfter"); + if (limit <= 0) { + throw new IllegalArgumentException("a quota of " + limit + " admits nothing"); + } + if (remaining < 0) { + throw new IllegalArgumentException("remaining quota cannot be negative"); + } + if (allowed && retryAfter.isPresent()) { + throw new IllegalArgumentException("an allowed request has nothing to wait for"); + } + if (!allowed && retryAfter.isEmpty()) { + throw new IllegalArgumentException( + "a refused request must say how long to wait; without it every client invents its own" + + " backoff and the impatient ones set the load"); + } + } + + /** + * A request inside its quota. + * + * @param limit the quota for the window + * @param remaining how much is left after this request + * @param resetAt when the window rolls over + */ + public static RateLimitDecision allowed(long limit, long remaining, Instant resetAt) { + return new RateLimitDecision(true, limit, remaining, resetAt, Optional.empty()); + } + + /** + * A request over its quota. + * + * @param limit the quota for the window + * @param resetAt when the window rolls over + * @param retryAfter how long to wait + */ + public static RateLimitDecision refused(long limit, Instant resetAt, Duration retryAfter) { + return new RateLimitDecision(false, limit, 0, resetAt, Optional.of(retryAfter)); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimitFailurePolicy.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimitFailurePolicy.java new file mode 100644 index 00000000..5e05dc2c --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimitFailurePolicy.java @@ -0,0 +1,30 @@ +package dev.caskeleton.adapter.inbound.web.ratelimit; + +/** + * What to do when the limiter's own store is unreachable. + * + *

An explicit choice per operation, because there is no answer that is right for all of them and + * the default is always wrong for half. It is declared on the operation profile so the decision is + * made by whoever knows what the endpoint does, at review time, rather than by whatever the limiter + * happened to do when Redis went away. + */ +public enum RateLimitFailurePolicy { + + /** + * Serve the request when the limiter cannot answer. + * + *

Right for reads and for anything whose worst case is load. Wrong for anything a quota is + * protecting from abuse rather than from volume: a login endpoint that fails open turns a Redis + * outage into an open brute-force window. + */ + FAIL_OPEN, + + /** + * Refuse the request when the limiter cannot answer. + * + *

Right where exceeding the quota is worse than being unavailable — sending messages, moving + * money, anything that costs per call. It converts a dependency outage into a total outage of + * that endpoint, which is the price of the guarantee. + */ + FAIL_CLOSED +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimitProfileName.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimitProfileName.java new file mode 100644 index 00000000..34928384 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/RateLimitProfileName.java @@ -0,0 +1,32 @@ +package dev.caskeleton.adapter.inbound.web.ratelimit; + +import java.util.Objects; +import java.util.regex.Pattern; + +/** + * The name of a quota profile. + * + * @param value the profile name + */ +public record RateLimitProfileName(String value) { + + private static final Pattern GRAMMAR = Pattern.compile("[a-z][a-z0-9-]{0,63}"); + + public RateLimitProfileName { + Objects.requireNonNull(value, "value"); + if (!GRAMMAR.matcher(value).matches()) { + throw new IllegalArgumentException( + "a rate limit profile name must match [a-z][a-z0-9-]{0,63}"); + } + } + + /** The profile every operation gets unless it declares another. */ + public static RateLimitProfileName standard() { + return new RateLimitProfileName("standard"); + } + + @Override + public String toString() { + return value; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/WebRateLimiter.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/WebRateLimiter.java new file mode 100644 index 00000000..2d51bee6 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/ratelimit/WebRateLimiter.java @@ -0,0 +1,25 @@ +package dev.caskeleton.adapter.inbound.web.ratelimit; + +import dev.caskeleton.adapter.inbound.web.core.WebRequestContext; + +/** + * A per-caller quota over a period of time. + * + *

A quota, not a capacity check. The two are routinely conflated and answer differently: + * exhausting a quota is the caller's own doing and is 429; running out of capacity is the service's + * state and is 503. A client told 429 knows to slow down; told 503 it knows the problem is not its + * rate. Merging them tells every client the wrong thing half the time. + * + * @see dev.caskeleton.adapter.inbound.web.admission.WebAdmissionController for the capacity half + */ +@FunctionalInterface +public interface WebRateLimiter { + + /** + * Charges one request against a quota. + * + * @param context who is asking and for what + * @param profile which quota applies + */ + RateLimitDecision evaluate(WebRequestContext context, RateLimitProfileName profile); +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/security/AuthenticationView.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/security/AuthenticationView.java new file mode 100644 index 00000000..8f074d09 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/security/AuthenticationView.java @@ -0,0 +1,60 @@ +package dev.caskeleton.adapter.inbound.web.security; + +import java.util.Objects; +import java.util.Optional; +import java.util.Set; + +/** + * What the security layer established, as this platform needs to read it. + * + *

A narrow view rather than Spring's {@code Authentication}. The design's rule is that the web + * layer does not re-implement token verification, and the way to hold that rule is to give the web + * layer a type that cannot verify anything: there is nowhere here to put a signature, a key or a + * claim set, so the only thing this platform can do with an identity is read a decision somebody + * else already made. + * + *

It also keeps the bridge unit testable without a security context, which is what makes the + * cross-tenant case cheap enough to assert. + * + * @param authenticated whether the security layer verified the caller + * @param subject the verified subject, empty when unauthenticated + * @param authorities the granted authorities + * @param tenantId the tenant the credentials are scoped to, empty when the caller is not scoped + */ +public record AuthenticationView( + boolean authenticated, + Optional subject, + Set authorities, + Optional tenantId) { + + public AuthenticationView { + Objects.requireNonNull(subject, "subject"); + Objects.requireNonNull(authorities, "authorities"); + Objects.requireNonNull(tenantId, "tenantId"); + authorities = Set.copyOf(authorities); + if (authenticated && subject.isEmpty()) { + throw new IllegalArgumentException("an authenticated view must carry a subject"); + } + if (!authenticated && subject.isPresent()) { + throw new IllegalArgumentException( + "an unauthenticated view must not carry a subject; that pairing is how an unverified" + + " identifier reaches an audit record looking verified"); + } + } + + /** The view for a request the security layer did not authenticate. */ + public static AuthenticationView anonymous() { + return new AuthenticationView(false, Optional.empty(), Set.of(), Optional.empty()); + } + + /** A verified caller with no tenant scope. */ + public static AuthenticationView authenticated(String subject, Set authorities) { + return new AuthenticationView(true, Optional.of(subject), authorities, Optional.empty()); + } + + /** A verified caller scoped to a tenant. */ + public static AuthenticationView authenticated( + String subject, Set authorities, String tenantId) { + return new AuthenticationView(true, Optional.of(subject), authorities, Optional.of(tenantId)); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/security/CorsProfile.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/security/CorsProfile.java new file mode 100644 index 00000000..5679e864 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/security/CorsProfile.java @@ -0,0 +1,88 @@ +package dev.caskeleton.adapter.inbound.web.security; + +import java.time.Duration; +import java.util.Locale; +import java.util.Objects; +import java.util.Set; + +/** + * Which cross-origin callers may reach this API, and with what. + * + *

CORS is a browser-enforced relaxation of the same-origin policy, which means every entry here + * is a permission granted to a page the operator does not control. The validator refuses the + * combinations that grant more than anyone intends. + * + * @param allowedOrigins exact origins, or the single wildcard + * @param allowedMethods methods a preflight may approve + * @param allowedHeaders request headers a preflight may approve + * @param exposedHeaders response headers script may read + * @param allowCredentials whether cookies and TLS client certs may be sent + * @param maxAge how long a browser may cache the preflight + */ +public record CorsProfile( + Set allowedOrigins, + Set allowedMethods, + Set allowedHeaders, + Set exposedHeaders, + boolean allowCredentials, + Duration maxAge) { + + /** The wildcard origin. */ + public static final String ANY_ORIGIN = "*"; + + public CorsProfile { + Objects.requireNonNull(allowedOrigins, "allowedOrigins"); + Objects.requireNonNull(allowedMethods, "allowedMethods"); + Objects.requireNonNull(allowedHeaders, "allowedHeaders"); + Objects.requireNonNull(exposedHeaders, "exposedHeaders"); + Objects.requireNonNull(maxAge, "maxAge"); + allowedOrigins = Set.copyOf(allowedOrigins); + allowedMethods = Set.copyOf(allowedMethods); + allowedHeaders = Set.copyOf(allowedHeaders); + exposedHeaders = Set.copyOf(exposedHeaders); + if (maxAge.isNegative()) { + throw new IllegalArgumentException("a negative preflight cache age is not an age"); + } + } + + /** + * A profile for a browser front end on a known origin. + * + * @param origins the exact origins the front end is served from + */ + public static CorsProfile credentialedFrontEnd(Set origins) { + return new CorsProfile( + origins, + Set.of("GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"), + Set.of("Content-Type", "Idempotency-Key", "If-Match", "If-None-Match"), + Set.of("ETag", "Location", "Retry-After"), + true, + Duration.ofMinutes(10)); + } + + /** A profile for a public read-only API that carries no credentials. */ + public static CorsProfile publicReadOnly() { + return new CorsProfile( + Set.of(ANY_ORIGIN), + Set.of("GET", "HEAD"), + Set.of("Content-Type"), + Set.of("ETag"), + false, + Duration.ofMinutes(10)); + } + + /** Whether the wildcard origin is allowed. */ + public boolean allowsAnyOrigin() { + return allowedOrigins.contains(ANY_ORIGIN); + } + + /** Whether an origin is permitted, matched exactly and case-sensitively on host. */ + public boolean permits(String origin) { + if (origin == null) { + return false; + } + // Exact match, never a suffix or prefix test. "endsWith(\".example.com\")" is the classic + // mistake and it admits "evil-example.com" and "example.com.attacker.net" alike. + return allowsAnyOrigin() || allowedOrigins.contains(origin.toLowerCase(Locale.ROOT)); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/security/CsrfProfile.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/security/CsrfProfile.java new file mode 100644 index 00000000..428ba0ac --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/security/CsrfProfile.java @@ -0,0 +1,44 @@ +package dev.caskeleton.adapter.inbound.web.security; + +import java.util.Objects; + +/** + * Whether CSRF protection applies, and why. + * + *

The rationale is a field rather than a comment because "CSRF is disabled here" is a claim that + * has to survive a review a year later. A profile that carries the threat-model reason it was + * disabled can be re-examined; a boolean cannot, and the usual outcome is that nobody dares change + * it and nobody can say why it is that way. + * + * @param enabled whether a CSRF token is required for unsafe methods + * @param rationale why, in terms of what a browser will and will not attach + */ +public record CsrfProfile(boolean enabled, String rationale) { + + public CsrfProfile { + Objects.requireNonNull(rationale, "rationale"); + if (rationale.isBlank()) { + throw new IllegalArgumentException( + "a CSRF decision without a stated reason cannot be reviewed, only inherited"); + } + } + + /** Required, because the browser attaches the credential by itself. */ + public static CsrfProfile required() { + return new CsrfProfile( + true, + "the credential is attached by the browser without the page asking, so a cross-site" + + " request authenticates on its own"); + } + + /** + * Not required, on the stated grounds that no browser attaches this credential. + * + *

Named for what it is — a reviewed exemption, not an absence. It applies only to a mode where + * nothing is ambiently attached, which the resolver enforces rather than trusting the caller to + * check. + */ + public static CsrfProfile explicitlyReviewed(String threatModel) { + return new CsrfProfile(false, threatModel); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/security/SecurityIdentity.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/security/SecurityIdentity.java new file mode 100644 index 00000000..ebacf4fc --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/security/SecurityIdentity.java @@ -0,0 +1,22 @@ +package dev.caskeleton.adapter.inbound.web.security; + +import dev.caskeleton.adapter.inbound.web.core.ActorContext; +import dev.caskeleton.adapter.inbound.web.core.TenantContext; +import java.util.Objects; + +/** + * Who the caller is and which tenant they are scoped to, resolved together. + * + *

Together on purpose: an actor without its tenant is an identity that has not been scoped yet, + * and code that receives the two separately can use one before the other has been established. + * + * @param actor the verified caller + * @param tenant the tenant the request is scoped to + */ +public record SecurityIdentity(ActorContext actor, TenantContext tenant) { + + public SecurityIdentity { + Objects.requireNonNull(actor, "actor"); + Objects.requireNonNull(tenant, "tenant"); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/security/UntrustedTenantInputException.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/security/UntrustedTenantInputException.java new file mode 100644 index 00000000..ceb95d33 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/security/UntrustedTenantInputException.java @@ -0,0 +1,23 @@ +package dev.caskeleton.adapter.inbound.web.security; + +/** + * A tenant was proposed by request input rather than established by authentication. + * + *

Its own exception because this is the cross-tenant read, not a validation nicety. A tenant + * taken from a header or a query parameter is chosen by the caller, so promoting one to the + * security context means the caller has selected which tenant's data the request operates on. + * + *

The message names no value. Echoing the proposed tenant would put attacker-chosen content into + * the log line that reports the attempt. + */ +public final class UntrustedTenantInputException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + /** Creates the failure. */ + public UntrustedTenantInputException() { + super( + "a tenant was supplied by request input; only an authenticated tenant may scope a request," + + " because a caller-chosen one selects whose data is read"); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/security/WebActorContextResolver.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/security/WebActorContextResolver.java new file mode 100644 index 00000000..8bf20591 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/security/WebActorContextResolver.java @@ -0,0 +1,29 @@ +package dev.caskeleton.adapter.inbound.web.security; + +import dev.caskeleton.adapter.inbound.web.core.ActorContext; +import java.util.Objects; + +/** + * Turns a verified authentication into the platform's actor. + * + *

It takes {@link AuthenticationView} and nothing else — no request, no headers, no parameters. + * That is the whole enforcement: there is no argument here through which a caller-supplied value + * could reach an {@link ActorContext}, so the rule "an actor comes from the security context" is + * structural rather than remembered. + */ +public final class WebActorContextResolver { + + /** + * The actor for an authentication. + * + * @param authentication what the security layer established + */ + public ActorContext resolve(AuthenticationView authentication) { + Objects.requireNonNull(authentication, "authentication"); + if (!authentication.authenticated()) { + return ActorContext.anonymous(); + } + return ActorContext.authenticated( + authentication.subject().orElseThrow(), authentication.authorities()); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/security/WebCorsPolicyValidator.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/security/WebCorsPolicyValidator.java new file mode 100644 index 00000000..c634486d --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/security/WebCorsPolicyValidator.java @@ -0,0 +1,96 @@ +package dev.caskeleton.adapter.inbound.web.security; + +import java.net.URI; +import java.util.Locale; +import java.util.Objects; + +/** + * Refuses CORS profiles that grant more than anyone means to grant. + * + *

At construction, not per request. Every rule here describes a configuration that a browser + * will happily honour and that no test of the API's own behaviour would ever notice — the API + * answers normally, and the only observable difference is which foreign pages can read the answer. + * A misconfiguration that is invisible in testing has to be refused at startup or it ships. + */ +public final class WebCorsPolicyValidator { + + /** + * Refuses an unsafe profile. + * + * @param profile the profile to check + * @throws IllegalStateException when the profile grants more than it should + */ + public void validate(CorsProfile profile) { + Objects.requireNonNull(profile, "profile"); + + if (profile.allowCredentials() && profile.allowsAnyOrigin()) { + // The one every browser already refuses, which is exactly why it must be refused here too: + // the deployment appears to work in testing against a same-origin front end and fails only + // for the cross-origin callers it was configured for, with a console error nobody sees. + throw new IllegalStateException( + "credentialed CORS requires an exact origin allowlist: '*' with credentials would let" + + " any page on the internet make authenticated requests as the visiting user"); + } + + for (String origin : profile.allowedOrigins()) { + if (CorsProfile.ANY_ORIGIN.equals(origin)) { + continue; + } + requireExactOrigin(origin); + } + + if (profile.allowCredentials() && profile.allowedHeaders().contains("*")) { + throw new IllegalStateException( + "a credentialed profile must name the headers it accepts: '*' is not honoured with" + + " credentials by browsers, so the deployment fails only for real callers"); + } + + if (profile.allowedMethods().isEmpty()) { + throw new IllegalStateException( + "a CORS profile that approves no method rejects every preflight, which reads to a client" + + " as the endpoint being broken rather than as a policy"); + } + } + + private static void requireExactOrigin(String origin) { + if (origin.isBlank()) { + throw new IllegalStateException("a blank origin is not an origin"); + } + if (origin.contains("*")) { + // Checked before parsing: a wildcard makes the value an invalid URI, so leaving this until + // after URI.create reports it as a malformed origin and hides what is actually wrong. + // + // A wildcard anywhere but as the whole value is not a wildcard at all — it is a literal + // asterisk in a hostname, and it matches nothing while reading as though it matches a family. + throw new IllegalStateException( + "origin " + + origin + + " contains a wildcard; CORS matches origins exactly, so this" + + " matches nothing while appearing to match a family of hosts"); + } + if (!origin.equals(origin.toLowerCase(Locale.ROOT))) { + // Origins are compared as strings against what the browser sends, which is lowercase. An + // uppercase entry simply never matches, and the failure looks like the allowlist being + // ignored rather than being wrong. + throw new IllegalStateException( + "origin " + + origin + + " must be lowercase; a browser sends it lowercase and it would" + + " never match"); + } + URI parsed; + try { + parsed = URI.create(origin); + } catch (IllegalArgumentException malformed) { + throw new IllegalStateException("origin " + origin + " is not a URI", malformed); + } + if (parsed.getScheme() == null || parsed.getHost() == null) { + throw new IllegalStateException( + "origin " + origin + " must be scheme://host[:port]; a bare host never matches"); + } + if (parsed.getPath() != null && !parsed.getPath().isEmpty()) { + throw new IllegalStateException( + "origin " + origin + " must carry no path; an origin is scheme, host and port only"); + } + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/security/WebCredentialMode.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/security/WebCredentialMode.java new file mode 100644 index 00000000..2efd1eca --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/security/WebCredentialMode.java @@ -0,0 +1,42 @@ +package dev.caskeleton.adapter.inbound.web.security; + +/** + * How a caller proves who it is. + * + *

This is the single fact CSRF protection turns on, so it is a declared mode rather than + * something inferred per request. CSRF exists because browsers attach some credentials + * automatically: a cookie travels with a cross-site request whether or not the page meant to send + * it, and an {@code Authorization} header does not. Everything else about CSRF follows from that + * one distinction, and getting it wrong in either direction is expensive — protection omitted where + * cookies are used is a working attack, and protection demanded where they are not breaks every + * non-browser client for no gain. + */ +public enum WebCredentialMode { + + /** A session cookie the browser attaches on its own. */ + SESSION_COOKIE, + + /** A cookie minted by a backend-for-frontend, attached the same way. */ + BFF_COOKIE, + + /** + * Both a cookie and a bearer token are accepted. + * + *

The dangerous one, and the reason this enum has five members rather than two. A cookie the + * endpoint merely *accepts* is enough: an attacker's cross-site form omits the header and the + * browser supplies the cookie, so the request authenticates. Reasoning about the header path and + * concluding CSRF is unnecessary is the mistake this constant exists to make visible. + */ + COOKIE_AND_BEARER, + + /** Only an {@code Authorization} header, which no browser attaches by itself. */ + AUTHORIZATION_HEADER_ONLY, + + /** mTLS or a signed service credential; no browser is involved at all. */ + SERVICE_TO_SERVICE; + + /** Whether a browser attaches this credential without the page asking. */ + public boolean ambientlyAttached() { + return this == SESSION_COOKIE || this == BFF_COOKIE || this == COOKIE_AND_BEARER; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/security/WebCsrfPolicyResolver.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/security/WebCsrfPolicyResolver.java new file mode 100644 index 00000000..348a0596 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/security/WebCsrfPolicyResolver.java @@ -0,0 +1,43 @@ +package dev.caskeleton.adapter.inbound.web.security; + +import java.util.Objects; + +/** + * Derives the CSRF requirement from how the caller authenticates. + * + *

Derived, never configured. A per-endpoint CSRF switch is a switch somebody eventually turns + * off to unbreak a client, and the endpoint that gets it turned off is usually the one where it + * mattered. Here the answer follows from the credential mode, so disabling protection requires + * changing what the endpoint accepts — which is a decision a reviewer will notice. + */ +public final class WebCsrfPolicyResolver { + + /** + * The CSRF profile for a credential mode. + * + * @param mode how the caller authenticates + */ + public CsrfProfile resolve(WebCredentialMode mode) { + Objects.requireNonNull(mode, "mode"); + return mode.ambientlyAttached() + ? CsrfProfile.required() + : CsrfProfile.explicitlyReviewed(reviewedThreatModelFor(mode)); + } + + private static String reviewedThreatModelFor(WebCredentialMode mode) { + return switch (mode) { + case AUTHORIZATION_HEADER_ONLY -> + "the endpoint accepts no cookie, so a cross-site request carries no credential and" + + " authenticates as nobody; the exemption ends the moment a cookie is accepted"; + case SERVICE_TO_SERVICE -> + "the caller is not a browser and the credential is a client certificate or a signed" + + " token that no user agent attaches on its own"; + // Unreachable while ambientlyAttached() and this switch agree. Stated as a failure rather + // than a default so that adding a mode to the enum without deciding this cannot compile + // into a silent exemption. + case SESSION_COOKIE, BFF_COOKIE, COOKIE_AND_BEARER -> + throw new IllegalStateException( + mode + " is ambiently attached and cannot be exempt from CSRF"); + }; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/security/WebSecurityContextBridge.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/security/WebSecurityContextBridge.java new file mode 100644 index 00000000..6aaa56cb --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/security/WebSecurityContextBridge.java @@ -0,0 +1,63 @@ +package dev.caskeleton.adapter.inbound.web.security; + +import java.util.Map; +import java.util.Objects; + +/** + * The one place an authentication becomes an actor and a tenant. + * + *

Route access and object authorization are deliberately different questions, and this class + * only answers the first. Being authenticated says a caller may reach the endpoint; it says nothing + * about whether they may read the particular resource the path names. Treating authentication as if + * it settled both is how an ordinary user reads another user's order by changing the id, so the + * platform keeps object authorization in the application where the resource is actually known. + * + *

The web layer never verifies a token. It reads a decision the security layer already made, + * which is why {@link AuthenticationView} has nowhere to put a signature or a claim set. + */ +public final class WebSecurityContextBridge { + + private final WebActorContextResolver actorResolver; + private final WebTenantContextResolver tenantResolver; + + /** A bridge over the platform's resolvers. */ + public WebSecurityContextBridge() { + this(new WebActorContextResolver(), new WebTenantContextResolver()); + } + + /** + * A bridge over explicit resolvers. + * + * @param actorResolver turns an authentication into an actor + * @param tenantResolver turns an authentication into a tenant scope + */ + public WebSecurityContextBridge( + WebActorContextResolver actorResolver, WebTenantContextResolver tenantResolver) { + this.actorResolver = Objects.requireNonNull(actorResolver, "actorResolver"); + this.tenantResolver = Objects.requireNonNull(tenantResolver, "tenantResolver"); + } + + /** + * The identity for an authentication. + * + * @param authentication what the security layer established + */ + public SecurityIdentity resolve(AuthenticationView authentication) { + Objects.requireNonNull(authentication, "authentication"); + return new SecurityIdentity( + actorResolver.resolve(authentication), tenantResolver.resolve(authentication)); + } + + /** + * The identity for an authentication, refusing any tenant the request tried to propose. + * + * @param authentication what the security layer established + * @param requestInput headers or parameters that arrived with the request + * @throws UntrustedTenantInputException when the request proposed a tenant + */ + public SecurityIdentity resolve( + AuthenticationView authentication, Map requestInput) { + tenantResolver.rejectTenantInput(requestInput); + return resolve(authentication); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/security/WebTenantContextResolver.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/security/WebTenantContextResolver.java new file mode 100644 index 00000000..8cf47c6f --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/security/WebTenantContextResolver.java @@ -0,0 +1,51 @@ +package dev.caskeleton.adapter.inbound.web.security; + +import dev.caskeleton.adapter.inbound.web.core.TenantContext; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; + +/** + * Turns a verified authentication into the platform's tenant scope, and refuses anything else. + * + *

{@link #resolve} takes only the authentication. {@link #rejectTenantInput} exists so the + * request path can state, explicitly and testably, that a header or parameter proposing a tenant is + * an error rather than something that was quietly ignored — ignoring leaves a cross-tenant attempt + * invisible, and the same client keeps trying. + */ +public final class WebTenantContextResolver { + + /** Header and parameter names that would be a tenant if anybody believed them. */ + private static final java.util.Set TENANT_INPUT_NAMES = + java.util.Set.of("x-tenant-id", "tenant-id", "tenantid", "tenant"); + + /** + * The tenant scope for an authentication. + * + * @param authentication what the security layer established + */ + public TenantContext resolve(AuthenticationView authentication) { + Objects.requireNonNull(authentication, "authentication"); + return authentication.tenantId().map(TenantContext::resolved).orElseGet(TenantContext::none); + } + + /** + * Refuses request input that proposes a tenant. + * + * @param requestInput headers or parameters, keyed by name + * @throws UntrustedTenantInputException when any of them names a tenant + */ + public void rejectTenantInput(Map requestInput) { + if (requestInput == null || requestInput.isEmpty()) { + return; + } + boolean proposed = + requestInput.keySet().stream() + .filter(Objects::nonNull) + .map(name -> name.toLowerCase(Locale.ROOT)) + .anyMatch(TENANT_INPUT_NAMES::contains); + if (proposed) { + throw new UntrustedTenantInputException(); + } + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/validation/TransportValidationException.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/validation/TransportValidationException.java new file mode 100644 index 00000000..65ab7a8a --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/validation/TransportValidationException.java @@ -0,0 +1,48 @@ +package dev.caskeleton.adapter.inbound.web.validation; + +import dev.caskeleton.adapter.inbound.web.error.ValidationIssue; +import java.util.List; + +/** + * A request that parsed and bound cleanly but violates a transport-level rule. + * + *

Its own type because the status depends on it. A document that is not JSON is a 400 — the + * client's message was unintelligible. A document that was understood and is not acceptable is a + * 422, and the difference tells a client whether to fix its serialiser or its data. + * + *

Transport validation is deliberately narrow: shape, ranges, cross-field consistency, anything + * decidable from the request alone. It must not reach a database, an HTTP client or a broker. A + * rule that needs to look something up is a business rule, it belongs in the application, and + * enforcing it here would put a query on the path of every malformed request an attacker sends. + */ +public final class TransportValidationException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + private final transient List issues; + + /** + * Creates a failure with no field-level detail. + * + * @param message what was wrong, carrying no submitted content + */ + public TransportValidationException(String message) { + this(message, List.of()); + } + + /** + * Creates a failure with field-level detail. + * + * @param message what was wrong, carrying no submitted content + * @param issues the field-level issues + */ + public TransportValidationException(String message, List issues) { + super(message); + this.issues = List.copyOf(issues); + } + + /** The field-level issues, empty when the failure is not field-level. */ + public List issues() { + return issues; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/validation/WebInputPointer.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/validation/WebInputPointer.java new file mode 100644 index 00000000..dc30f22b --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/validation/WebInputPointer.java @@ -0,0 +1,66 @@ +package dev.caskeleton.adapter.inbound.web.validation; + +import java.util.List; +import java.util.Objects; + +/** + * Builds the JSON Pointer that names the offending member of a request document. + * + *

A pointer into the document the client sent, never a path through the Java model. The client + * can act on {@code /order/lines/0/quantity}; it cannot act on {@code + * createOrder.arg0.lines[0].quantity}, which additionally publishes the shape of an internal method + * signature to anybody who sends a bad request. + * + *

Segments are escaped per RFC 6901. A field literally named {@code a/b} would otherwise produce + * a pointer that addresses a nested member that does not exist. + */ +public final class WebInputPointer { + + /** The pointer to the document root, used when a failure is not field-level. */ + public static final String ROOT = ""; + + private WebInputPointer() {} + + /** + * A pointer from ordered path segments. + * + * @param segments the member names and array indexes, outermost first + */ + public static String of(List segments) { + Objects.requireNonNull(segments, "segments"); + if (segments.isEmpty()) { + return ROOT; + } + StringBuilder pointer = new StringBuilder(); + for (String segment : segments) { + pointer.append('/').append(escape(Objects.requireNonNull(segment, "segment"))); + } + return pointer.toString(); + } + + /** + * Converts a Bean Validation property path into a pointer. + * + *

Bean Validation reports {@code lines[0].quantity}; a client needs {@code /lines/0/quantity}. + * The leading method and argument segments that Jakarta adds for a method-level constraint are + * dropped, because they name a Java signature rather than anything the client sent. + * + * @param propertyPath the Bean Validation path + */ + public static String fromPropertyPath(String propertyPath) { + if (propertyPath == null || propertyPath.isBlank()) { + return ROOT; + } + String working = propertyPath.replace("[", ".").replace("]", ""); + List segments = + java.util.Arrays.stream(working.split("\\.")) + .filter(segment -> !segment.isBlank()) + .filter(segment -> !segment.matches("arg\\d+")) + .toList(); + return of(segments); + } + + private static String escape(String segment) { + return segment.replace("~", "~0").replace("/", "~1"); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/validation/WebValidationExceptionMapper.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/validation/WebValidationExceptionMapper.java new file mode 100644 index 00000000..cdefe589 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/validation/WebValidationExceptionMapper.java @@ -0,0 +1,78 @@ +package dev.caskeleton.adapter.inbound.web.validation; + +import dev.caskeleton.adapter.inbound.web.error.ProblemCode; +import dev.caskeleton.adapter.inbound.web.json.WebJsonDecodingException; +import java.util.Optional; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.exc.InvalidFormatException; +import tools.jackson.databind.exc.MismatchedInputException; +import tools.jackson.databind.exc.UnrecognizedPropertyException; + +/** + * Decides whether a rejected request is a 400 or a 422. + * + *

The split is the design's and it is a real distinction rather than a stylistic one. A 400 says + * "I could not understand your message"; a 422 says "I understood it and it is not acceptable". A + * client acts on them differently: the first is a bug in how it builds requests, the second is a + * problem with the data a user typed. Collapsing both into 400 — which is the common shortcut — + * means a client cannot tell a serialisation bug from a rejected form. + * + *

Everything that fails before or during binding is a 400. That includes an unknown enum value + * and a string where a number belongs: those are decided by the shape of the document, not by its + * meaning, and the strict mapper refuses them at parse time. + * + *

An unmapped failure throws rather than defaulting. A default here would quietly answer some + * future exception with somebody's guess at a status, and the guess would be wrong in exactly the + * cases nobody anticipated. + */ +public final class WebValidationExceptionMapper { + + /** + * The problem code for a rejected request. + * + * @param failure the failure raised while reading or validating the request + * @throws IllegalArgumentException when the failure has no declared mapping + */ + public ProblemCode codeFor(Throwable failure) { + return find(failure) + .orElseThrow( + () -> + new IllegalArgumentException( + "unmapped validation failure: " + + (failure == null ? "null" : failure.getClass().getName()) + + "; a default status here would be a guess published to a client", + failure)); + } + + /** The problem code for a failure, when one is declared. */ + public Optional find(Throwable failure) { + if (failure == null) { + return Optional.empty(); + } + if (failure instanceof TransportValidationException) { + return Optional.of(ProblemCode.VALIDATION_FAILED); + } + if (failure instanceof WebJsonDecodingException decoding) { + return Optional.of( + decoding.malformed() ? ProblemCode.MALFORMED_REQUEST : ProblemCode.BINDING_FAILED); + } + // An unrecognised property and a wrong scalar shape are decided by the document's structure, + // so they are read failures rather than semantic ones. + if (failure instanceof UnrecognizedPropertyException + || failure instanceof InvalidFormatException) { + return Optional.of(ProblemCode.BINDING_FAILED); + } + if (failure instanceof MismatchedInputException) { + return Optional.of(ProblemCode.BINDING_FAILED); + } + if (failure instanceof JacksonException) { + return Optional.of(ProblemCode.MALFORMED_REQUEST); + } + return Optional.empty(); + } + + /** Whether a failure is one this mapper can classify. */ + public boolean handles(Throwable failure) { + return find(failure).isPresent(); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/validation/WebValidationIssueMapper.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/validation/WebValidationIssueMapper.java new file mode 100644 index 00000000..2ca25890 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/validation/WebValidationIssueMapper.java @@ -0,0 +1,95 @@ +package dev.caskeleton.adapter.inbound.web.validation; + +import dev.caskeleton.adapter.inbound.web.error.ValidationIssue; +import jakarta.validation.ConstraintViolation; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * Turns Bean Validation violations into the platform's field-level issues. + * + *

Three things change on the way across, and each is a leak that would otherwise be published. + * The property path becomes a JSON Pointer, so a client can act on it and the internal method + * signature stays private. The constraint annotation becomes a stable code, so a client can branch + * on {@code must-be-positive} rather than on a message that gets reworded or translated. And the + * invalid value is dropped entirely — {@code ConstraintViolation} carries it, and echoing it back + * is how a password typed into the wrong field reaches a log aggregator. + * + *

Issues are sorted by pointer so two identical failures produce byte-identical bodies. An error + * response whose member order varies between runs cannot be asserted in a contract test, and a + * client that caches by response hash sees churn that is not there. + */ +public final class WebValidationIssueMapper { + + /** + * Maps a set of violations. + * + * @param violations the Bean Validation result + */ + public List toIssues(Set> violations) { + Objects.requireNonNull(violations, "violations"); + return violations.stream() + .map(this::toIssue) + .sorted( + java.util.Comparator.comparing(ValidationIssue::pointer) + .thenComparing(ValidationIssue::code)) + .collect(Collectors.toUnmodifiableList()); + } + + /** Maps one violation. */ + public ValidationIssue toIssue(ConstraintViolation violation) { + Objects.requireNonNull(violation, "violation"); + String pointer = + WebInputPointer.fromPropertyPath( + violation.getPropertyPath() == null ? "" : violation.getPropertyPath().toString()); + return new ValidationIssue(pointer, codeFor(violation), safeMessage(violation)); + } + + /** + * The stable code for a constraint. + * + *

Derived from the annotation type rather than the message: {@code @NotBlank} is {@code + * must-not-be-blank} in every locale and after every reword. + */ + private String codeFor(ConstraintViolation violation) { + if (violation.getConstraintDescriptor() == null + || violation.getConstraintDescriptor().getAnnotation() == null) { + return "invalid"; + } + String annotation = + violation.getConstraintDescriptor().getAnnotation().annotationType().getSimpleName(); + return switch (annotation) { + case "NotNull" -> "must-not-be-null"; + case "NotBlank" -> "must-not-be-blank"; + case "NotEmpty" -> "must-not-be-empty"; + case "Size" -> "size-out-of-range"; + case "Min", "Positive", "PositiveOrZero" -> "must-be-at-least"; + case "Max", "Negative", "NegativeOrZero" -> "must-be-at-most"; + case "Pattern" -> "must-match-pattern"; + case "Email" -> "must-be-email"; + case "Past", "PastOrPresent" -> "must-be-in-the-past"; + case "Future", "FutureOrPresent" -> "must-be-in-the-future"; + default -> hyphenate(annotation); + }; + } + + /** + * The message, with the submitted value never interpolated in. + * + *

A Bean Validation message template can contain {@code ${validatedValue}}. Any message that + * still holds an interpolation marker, or that is absent, is replaced rather than published. + */ + private String safeMessage(ConstraintViolation violation) { + String message = violation.getMessage(); + if (message == null || message.isBlank() || message.contains("${")) { + return "value is not acceptable"; + } + return message; + } + + private static String hyphenate(String annotation) { + return annotation.replaceAll("([a-z])([A-Z])", "$1-$2").toLowerCase(java.util.Locale.ROOT); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/versioning/ApiDeprecationPolicy.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/versioning/ApiDeprecationPolicy.java new file mode 100644 index 00000000..4a54c62d --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/versioning/ApiDeprecationPolicy.java @@ -0,0 +1,60 @@ +package dev.caskeleton.adapter.inbound.web.versioning; + +import java.time.Instant; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Which routes are deprecated, and the rule that a sunset date is a commitment. + * + *

Registration is where the promise is checked. A route registered with a sunset that has + * already passed is a deployment still serving something it told clients was gone, and the platform + * refuses to publish that contradiction rather than emitting it on every response. + * + *

Deprecation is per route template, not per version. A version-wide sunset is expressed by + * registering its routes, which keeps the notice on the exact resources a client is calling. + */ +public final class ApiDeprecationPolicy { + + private final Map routes = new ConcurrentHashMap<>(); + + /** + * Registers a deprecation. + * + * @param route the deprecated route + * @param now the clock reading the sunset is checked against + * @throws SunsetViolationException when the sunset has already passed + */ + public void register(DeprecatedRoute route, Instant now) { + Objects.requireNonNull(route, "route"); + Objects.requireNonNull(now, "now"); + if (route.sunsetPassed(now)) { + throw new SunsetViolationException( + "sunset for " + + route.routeTemplate() + + " has already passed; publishing it would tell clients a served route is gone"); + } + if (routes.putIfAbsent(route.routeTemplate(), route) != null) { + throw new SunsetViolationException("duplicate deprecation for " + route.routeTemplate()); + } + } + + /** The deprecation for a route, when it has one. */ + public Optional find(String routeTemplate) { + return routeTemplate == null + ? Optional.empty() + : Optional.ofNullable(routes.get(routeTemplate)); + } + + /** Whether a route is deprecated. */ + public boolean deprecated(String routeTemplate) { + return find(routeTemplate).isPresent(); + } + + /** Every registered deprecation, for a startup report. */ + public Map registered() { + return Map.copyOf(routes); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/versioning/ApiVersionCatalog.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/versioning/ApiVersionCatalog.java new file mode 100644 index 00000000..db139737 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/versioning/ApiVersionCatalog.java @@ -0,0 +1,66 @@ +package dev.caskeleton.adapter.inbound.web.versioning; + +import dev.caskeleton.adapter.inbound.web.core.ApiMajorVersion; +import java.util.Comparator; +import java.util.Objects; +import java.util.Set; +import java.util.TreeSet; + +/** + * The major versions this deployment serves. + * + *

An explicit set rather than "anything that routes". A version that exists because somebody + * added a controller under a new path is a version nobody decided to support, and the first thing + * that notices is a client depending on it. + * + *

Only majors. The design's rule is that minor and patch evolution stays additive and therefore + * invisible in the URL: putting a minor in the path makes every additive change a new URL, which is + * the opposite of what versioning is for. + */ +public final class ApiVersionCatalog { + + private final Set supported; + + /** + * A catalog of served versions. + * + * @param supported the majors this deployment serves, at least one + */ + public ApiVersionCatalog(Set supported) { + Objects.requireNonNull(supported, "supported"); + if (supported.isEmpty()) { + throw new IllegalArgumentException("an API that serves no version is not an API"); + } + Set ordered = new TreeSet<>(Comparator.comparingInt(ApiMajorVersion::value)); + ordered.addAll(supported); + this.supported = Set.copyOf(ordered); + } + + /** A catalog serving only v1, which is the Stable default. */ + public static ApiVersionCatalog v1() { + return new ApiVersionCatalog(Set.of(new ApiMajorVersion(1))); + } + + /** + * Confirms a version is served. + * + * @throws UnsupportedApiVersionException when it is not + */ + public ApiMajorVersion requireSupported(ApiMajorVersion version) { + Objects.requireNonNull(version, "version"); + if (!supported.contains(version)) { + throw UnsupportedApiVersionException.unknownMajor(version.value()); + } + return version; + } + + /** Whether a version is served. */ + public boolean supports(ApiMajorVersion version) { + return version != null && supported.contains(version); + } + + /** Every served version. */ + public Set supported() { + return supported; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/versioning/DeprecatedRoute.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/versioning/DeprecatedRoute.java new file mode 100644 index 00000000..161119cf --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/versioning/DeprecatedRoute.java @@ -0,0 +1,51 @@ +package dev.caskeleton.adapter.inbound.web.versioning; + +import java.net.URI; +import java.time.Instant; +import java.util.Objects; +import java.util.Optional; + +/** + * One route that is on its way out, with everything a client needs to move off it. + * + *

Three facts, and the constructor insists on the ones that make the notice actionable. A + * deprecation with no successor and no documentation is an instruction to stop with no instruction + * to start; a sunset already in the past is a service that is telling clients it removed something + * it is still serving. + * + * @param routeTemplate the route being deprecated, as a template + * @param deprecatedAt when the deprecation took effect + * @param sunsetAt when the route stops being served, when a date has been committed to + * @param documentation where a client reads what to do instead + * @param successor the route that replaces this one, when there is one + */ +public record DeprecatedRoute( + String routeTemplate, + Instant deprecatedAt, + Optional sunsetAt, + URI documentation, + Optional successor) { + + public DeprecatedRoute { + Objects.requireNonNull(routeTemplate, "routeTemplate"); + Objects.requireNonNull(deprecatedAt, "deprecatedAt"); + Objects.requireNonNull(sunsetAt, "sunsetAt"); + Objects.requireNonNull(documentation, "documentation"); + Objects.requireNonNull(successor, "successor"); + if (routeTemplate.isBlank()) { + throw new IllegalArgumentException("a deprecated route needs a template"); + } + if (sunsetAt.isPresent() && sunsetAt.get().isBefore(deprecatedAt)) { + throw new SunsetViolationException( + "sunset precedes deprecation for " + + routeTemplate + + "; a client would be told the route is already gone while it is still served"); + } + } + + /** Whether the sunset date has passed at an instant. */ + public boolean sunsetPassed(Instant now) { + Objects.requireNonNull(now, "now"); + return sunsetAt.isPresent() && !now.isBefore(sunsetAt.get()); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/versioning/DeprecationHeaderWriter.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/versioning/DeprecationHeaderWriter.java new file mode 100644 index 00000000..2a99176f --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/versioning/DeprecationHeaderWriter.java @@ -0,0 +1,70 @@ +package dev.caskeleton.adapter.inbound.web.versioning; + +import dev.caskeleton.adapter.inbound.web.http.WebHeaderName; +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; + +/** + * Renders the deprecation notice a response carries. + * + *

Headers rather than a body field, because the notice has to reach a client that is not reading + * the body: a cache, a generated SDK, a monitoring probe. Putting it in the payload also means + * every response shape has to carry it, which is how it ends up on some routes and not others. + * + *

{@code Sunset} is an HTTP-date per RFC 8594 — not ISO 8601, which is what a developer writes + * by hand and what a client's date parser then rejects. Rendering it here, once, from an {@code + * Instant} is what keeps the format out of every call site. + */ +public final class DeprecationHeaderWriter { + + /** RFC 8594 requires an IMF-fixdate, in GMT, in the C locale. */ + private static final DateTimeFormatter HTTP_DATE = + DateTimeFormatter.ofPattern("EEE, dd MMM yyyy HH:mm:ss 'GMT'", Locale.US) + .withZone(ZoneOffset.UTC); + + /** + * The headers a deprecated route's response carries. + * + * @param route the registered deprecation + * @return header name to value, in a deterministic order + */ + public Map headersFor(DeprecatedRoute route) { + Objects.requireNonNull(route, "route"); + Map headers = new LinkedHashMap<>(); + // RFC 9745 spells the Deprecation value as an HTTP-date too, so a client parses one format. + headers.put(WebHeaderName.DEPRECATION, HTTP_DATE.format(route.deprecatedAt())); + route + .sunsetAt() + .ifPresent(sunset -> headers.put(WebHeaderName.SUNSET, HTTP_DATE.format(sunset))); + StringBuilder link = new StringBuilder(); + link.append('<').append(route.documentation()).append(">; rel=\"deprecation\""); + route + .successor() + .ifPresent( + successor -> + link.append(", <").append(successor).append(">; rel=\"successor-version\"")); + headers.put(WebHeaderName.LINK, link.toString()); + return Map.copyOf(headers); + } + + /** + * The headers for a route, or none when it is not deprecated. + * + * @param policy the registered deprecations + * @param routeTemplate the route being served + */ + public Map headersFor(ApiDeprecationPolicy policy, String routeTemplate) { + Objects.requireNonNull(policy, "policy"); + return policy.find(routeTemplate).map(this::headersFor).orElseGet(Map::of); + } + + /** Renders an instant the way {@code Sunset} and {@code Deprecation} require. */ + public String httpDate(Instant instant) { + return HTTP_DATE.format(Objects.requireNonNull(instant, "instant")); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/versioning/PathApiVersionResolver.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/versioning/PathApiVersionResolver.java new file mode 100644 index 00000000..b4422262 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/versioning/PathApiVersionResolver.java @@ -0,0 +1,60 @@ +package dev.caskeleton.adapter.inbound.web.versioning; + +import dev.caskeleton.adapter.inbound.web.core.ApiMajorVersion; +import java.util.Objects; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Reads the major version out of a canonical path. + * + *

The pattern is anchored at the start and requires a segment boundary after the number. Both + * matter: an unanchored match would find {@code /v2} inside {@code /api/v1/tools/v2}, and without + * the boundary {@code /api/v10} would read as {@code v1}. Version confusion is not a cosmetic bug — + * it routes a request to a contract the client did not ask for. + * + *

A leading zero is refused by the pattern, so {@code /api/v01} is not a second spelling of + * {@code v1} that a cache would hold separately. + */ +public final class PathApiVersionResolver { + + private static final Pattern PATTERN = Pattern.compile("^/api/v([1-9][0-9]*)(?:/|$)"); + + private final ApiVersionCatalog catalog; + + /** + * A resolver over a catalog. + * + * @param catalog the versions this deployment serves + */ + public PathApiVersionResolver(ApiVersionCatalog catalog) { + this.catalog = Objects.requireNonNull(catalog, "catalog"); + } + + /** + * The version a path addresses. + * + * @param path a canonical application path + * @throws UnsupportedApiVersionException when the path has no version or names an unserved one + */ + public ApiMajorVersion resolve(String path) { + if (path == null) { + throw UnsupportedApiVersionException.missingVersion(); + } + Matcher matcher = PATTERN.matcher(path); + if (!matcher.find()) { + throw UnsupportedApiVersionException.missingVersion(); + } + return catalog.requireSupported(new ApiMajorVersion(Integer.parseInt(matcher.group(1)))); + } + + /** Whether a path addresses a version this deployment serves. */ + public boolean servedBy(String path) { + try { + resolve(path); + return true; + } catch (UnsupportedApiVersionException unsupported) { + return false; + } + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/versioning/SunsetViolationException.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/versioning/SunsetViolationException.java new file mode 100644 index 00000000..582bc80f --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/versioning/SunsetViolationException.java @@ -0,0 +1,18 @@ +package dev.caskeleton.adapter.inbound.web.versioning; + +/** + * A deprecation was declared in a way a client cannot act on. + * + *

Refused at registration rather than at request time. A sunset date in the past, or a + * deprecation with no documentation link, is a promise the deployment cannot keep — and discovering + * that when the first client asks is discovering it from the client. + */ +public final class SunsetViolationException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + /** Creates the failure. */ + public SunsetViolationException(String message) { + super(message); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/versioning/UnsupportedApiVersionException.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/versioning/UnsupportedApiVersionException.java new file mode 100644 index 00000000..9dd5f14e --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/versioning/UnsupportedApiVersionException.java @@ -0,0 +1,39 @@ +package dev.caskeleton.adapter.inbound.web.versioning; + +/** + * A request addressed a major version this deployment does not serve. + * + *

Its own type so the transport answers every unknown version the same way. Without it each + * handler decides: one 404s, one 400s, one falls through to the newest version — and the last is + * the dangerous one, because a client written against {@code /api/v3} silently receives {@code v1} + * semantics. + * + *

The message names the version and never the path. A path carries identifiers. + */ +public final class UnsupportedApiVersionException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + /** + * Creates the failure. + * + * @param detail what was wrong, naming no request content + */ + public UnsupportedApiVersionException(String detail) { + super(detail); + } + + /** The failure for a version outside the catalog. */ + public static UnsupportedApiVersionException unknownMajor(int major) { + return new UnsupportedApiVersionException( + "api major version v" + + major + + " is not served; falling through to the newest version would give a client written" + + " for a later contract the semantics of an earlier one"); + } + + /** The failure for a path with no version segment at all. */ + public static UnsupportedApiVersionException missingVersion() { + return new UnsupportedApiVersionException("the path carries no /api/v{major} version segment"); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/webflux/autoconfigure/WebFluxPlatformAutoConfiguration.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/webflux/autoconfigure/WebFluxPlatformAutoConfiguration.java new file mode 100644 index 00000000..fca7bd62 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/webflux/autoconfigure/WebFluxPlatformAutoConfiguration.java @@ -0,0 +1,131 @@ +package dev.caskeleton.adapter.inbound.web.webflux.autoconfigure; + +import dev.caskeleton.adapter.inbound.web.budget.WebBudgetCatalog; +import dev.caskeleton.adapter.inbound.web.budget.WebBudgetProfileName; +import dev.caskeleton.adapter.inbound.web.budget.WebRequestBudget; +import dev.caskeleton.adapter.inbound.web.error.ProblemCatalog; +import dev.caskeleton.adapter.inbound.web.error.WebProblemFactory; +import dev.caskeleton.adapter.inbound.web.error.WebProblemSanitizer; +import dev.caskeleton.adapter.inbound.web.http.WebMethodPolicy; +import dev.caskeleton.adapter.inbound.web.http.WebUriPolicy; +import dev.caskeleton.adapter.inbound.web.json.WebJsonProfile; +import dev.caskeleton.adapter.inbound.web.json.WebObjectMapperFactory; +import dev.caskeleton.adapter.inbound.web.operation.InMemoryWebOperationCatalog; +import dev.caskeleton.adapter.inbound.web.operation.WebOperationCatalog; +import dev.caskeleton.adapter.inbound.web.validation.WebValidationExceptionMapper; +import dev.caskeleton.adapter.inbound.web.webflux.context.WebFluxRequestContextFilter; +import dev.caskeleton.adapter.inbound.web.webflux.guard.BlockingDependencyGuard; +import java.time.Clock; +import java.time.Duration; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.core.Ordered; + +/** + * Wires the web platform into a reactive application. + * + *

{@link ConditionalOnWebApplication} with {@code REACTIVE} is the other half of the mutual + * exclusion the MVC starter declares with {@code SERVLET}. Spring Boot deduces exactly one + * application type, so the two roots can never both activate — which is a stronger guarantee than a + * startup check, because it holds before any bean is created. + * + *

The shared policy beans are declared here as well as in the MVC root rather than being pulled + * from a common configuration. Importing a shared configuration would make the servlet types it + * transitively references resolvable from the reactive starter, which is the dependency the design + * forbids; duplicating six {@code @Bean} methods is the cheaper of the two costs. + */ +@AutoConfiguration +@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.REACTIVE) +@ConditionalOnProperty( + prefix = "backend.web.webflux", + name = "enabled", + havingValue = "true", + matchIfMissing = true) +@EnableConfigurationProperties(WebFluxPlatformSettings.class) +public class WebFluxPlatformAutoConfiguration { + + /** Establishes the request context in the Reactor Context, never a thread-local. */ + @Bean + @ConditionalOnMissingBean + public WebFluxRequestContextFilter webFluxRequestContextFilter(WebFluxPlatformSettings settings) { + return new WebFluxRequestContextFilter( + Clock.systemUTC(), + Duration.ofSeconds(settings.requestBudgetSeconds()), + settings.trustInboundRequestId(), + Ordered.HIGHEST_PRECEDENCE + 10); + } + + /** Refuses a registered blocking dependency reached from an event-loop thread. */ + @Bean + @ConditionalOnMissingBean + @ConditionalOnProperty( + prefix = "backend.web.webflux", + name = "blocking-guard-enabled", + havingValue = "true", + matchIfMissing = true) + public BlockingDependencyGuard blockingDependencyGuard() { + return BlockingDependencyGuard.reactorNetty(); + } + + /** The catalog of registered operations. */ + @Bean + @ConditionalOnMissingBean(WebOperationCatalog.class) + public InMemoryWebOperationCatalog webOperationCatalog() { + return new InMemoryWebOperationCatalog(); + } + + /** The registered request budget profiles. */ + @Bean + @ConditionalOnMissingBean + public WebBudgetCatalog webBudgetCatalog() { + WebBudgetCatalog catalog = new WebBudgetCatalog(); + catalog.register(WebBudgetProfileName.standard(), WebRequestBudget.standard()); + return catalog; + } + + /** The published problem catalog. */ + @Bean + @ConditionalOnMissingBean + public ProblemCatalog webProblemCatalog() { + return ProblemCatalog.standard(); + } + + /** The only builder of problem bodies. */ + @Bean + @ConditionalOnMissingBean + public WebProblemFactory webProblemFactory(ProblemCatalog catalog) { + return new WebProblemFactory(catalog, new WebProblemSanitizer()); + } + + /** The 400/422 classifier. */ + @Bean + @ConditionalOnMissingBean + public WebValidationExceptionMapper webValidationExceptionMapper() { + return new WebValidationExceptionMapper(); + } + + /** The one accepted path spelling. */ + @Bean + @ConditionalOnMissingBean + public WebUriPolicy webUriPolicy() { + return WebUriPolicy.standard(); + } + + /** The method allowlist. */ + @Bean + @ConditionalOnMissingBean + public WebMethodPolicy webMethodPolicy() { + return WebMethodPolicy.standard(); + } + + /** The strict reader request bodies are read with. */ + @Bean + @ConditionalOnMissingBean(name = "webStrictObjectMapper") + public tools.jackson.databind.json.JsonMapper webStrictObjectMapper() { + return WebObjectMapperFactory.jsonMapper(WebJsonProfile.strict()); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/webflux/autoconfigure/WebFluxPlatformSettings.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/webflux/autoconfigure/WebFluxPlatformSettings.java new file mode 100644 index 00000000..5a2fc1b0 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/webflux/autoconfigure/WebFluxPlatformSettings.java @@ -0,0 +1,42 @@ +package dev.caskeleton.adapter.inbound.web.webflux.autoconfigure; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * The bound settings of the reactive web platform. + * + *

Named {@code Settings} rather than the design's {@code Properties} to satisfy this + * repository's naming convention, which requires every {@code @ConfigurationProperties} type to end + * in {@code Settings} or {@code Policy}. + * + *

{@code blockingGuardEnabled} defaults to on. A guard that has to be switched on is a guard the + * deployments that never read the documentation do not have, and those are the deployments where a + * blocking call on the event loop is most likely to have been written. + * + * @param enabled whether the platform's reactive wiring is installed at all + * @param trustInboundRequestId whether a caller may choose its own request id + * @param blockingGuardEnabled whether a blocking dependency on an event-loop thread fails + * @param requestBudgetSeconds how long a request may run before its deadline expires + */ +@ConfigurationProperties(prefix = "backend.web.webflux") +public record WebFluxPlatformSettings( + Boolean enabled, + Boolean trustInboundRequestId, + Boolean blockingGuardEnabled, + Integer requestBudgetSeconds) { + + public WebFluxPlatformSettings { + enabled = enabled == null || enabled; + trustInboundRequestId = trustInboundRequestId != null && trustInboundRequestId; + blockingGuardEnabled = blockingGuardEnabled == null || blockingGuardEnabled; + requestBudgetSeconds = requestBudgetSeconds == null ? 10 : requestBudgetSeconds; + if (requestBudgetSeconds <= 0) { + throw new IllegalArgumentException("request budget must be positive"); + } + } + + /** The defaults a deployment that configures nothing runs with. */ + public static WebFluxPlatformSettings defaults() { + return new WebFluxPlatformSettings(null, null, null, null); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/webflux/budget/BoundedServerWebExchange.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/webflux/budget/BoundedServerWebExchange.java new file mode 100644 index 00000000..81757850 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/webflux/budget/BoundedServerWebExchange.java @@ -0,0 +1,79 @@ +package dev.caskeleton.adapter.inbound.web.webflux.budget; + +import dev.caskeleton.adapter.inbound.web.budget.WebBudgetMeter; +import dev.caskeleton.adapter.inbound.web.budget.WebBudgetViolation; +import dev.caskeleton.adapter.inbound.web.budget.WebRequestBudget; +import java.util.Objects; +import org.reactivestreams.Publisher; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.http.server.reactive.ServerHttpRequestDecorator; +import org.springframework.http.server.reactive.ServerHttpResponse; +import org.springframework.http.server.reactive.ServerHttpResponseDecorator; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.ServerWebExchangeDecorator; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +/** + * An exchange whose request and response bodies are bounded as they stream. + * + *

Both sides are counted in {@code doOnNext} on the buffers, so nothing is held to be measured. + * That is not an optimisation here — a reactive body may legitimately never exist in full at any + * one moment, and a bound that required it to would have to buffer the very request it is trying to + * refuse. + */ +public final class BoundedServerWebExchange extends ServerWebExchangeDecorator { + + private final WebBudgetMeter requestMeter; + private final WebBudgetMeter responseMeter; + + /** + * A bounded view of one exchange. + * + * @param delegate the exchange being served + * @param budget the bounds to enforce + */ + public BoundedServerWebExchange(ServerWebExchange delegate, WebRequestBudget budget) { + super(delegate); + Objects.requireNonNull(budget, "budget"); + this.requestMeter = + new WebBudgetMeter(WebBudgetViolation.BODY_TOO_LARGE, budget.maxBodyBytes()); + this.responseMeter = + new WebBudgetMeter(WebBudgetViolation.RESPONSE_TOO_LARGE, budget.maxResponseBytes()); + } + + @Override + public ServerHttpRequest getRequest() { + return new ServerHttpRequestDecorator(super.getRequest()) { + @Override + public Flux getBody() { + return super.getBody().doOnNext(buffer -> requestMeter.add(buffer.readableByteCount())); + } + }; + } + + @Override + public ServerHttpResponse getResponse() { + return new ServerHttpResponseDecorator(super.getResponse()) { + @Override + public Mono writeWith(Publisher body) { + // Counted before the buffer is handed on, so the write that would cross the bound never + // reaches the socket. + return super.writeWith( + Flux.from(body).doOnNext(buffer -> responseMeter.add(buffer.readableByteCount()))); + } + + @Override + public Mono writeAndFlushWith( + Publisher> body) { + return super.writeAndFlushWith( + Flux.from(body) + .map( + part -> + Flux.from(part) + .doOnNext(buffer -> responseMeter.add(buffer.readableByteCount())))); + } + }; + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/webflux/budget/WebFluxBudgetFilter.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/webflux/budget/WebFluxBudgetFilter.java new file mode 100644 index 00000000..172eca76 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/webflux/budget/WebFluxBudgetFilter.java @@ -0,0 +1,122 @@ +package dev.caskeleton.adapter.inbound.web.webflux.budget; + +import dev.caskeleton.adapter.inbound.web.budget.WebBudgetExceededException; +import dev.caskeleton.adapter.inbound.web.budget.WebBudgetMeter; +import dev.caskeleton.adapter.inbound.web.budget.WebBudgetViolation; +import dev.caskeleton.adapter.inbound.web.budget.WebRequestBudget; +import dev.caskeleton.adapter.inbound.web.error.BudgetProblemMapper; +import dev.caskeleton.adapter.inbound.web.error.WebProblem; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.MediaType; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.WebFilter; +import org.springframework.web.server.WebFilterChain; +import reactor.core.publisher.Mono; +import tools.jackson.databind.json.JsonMapper; + +/** + * Enforces the request and response budgets on the reactive stack. + * + *

The reactive stack makes the streaming requirement unavoidable rather than merely advisable: a + * body here is a {@code Flux} that may never be fully in memory at once, so there is no + * "measure it then decide" to be tempted by. The meter runs in {@code doOnNext} on the buffers as + * they pass. + * + *

The reason this is not simply the servlet filter with different types is the response half. + * Once a reactive response is committed the pipeline is already emitting, and the only truthful end + * to an overrun is to fail the publisher — which drops the connection mid-document rather than + * finishing a short one the client would accept. + */ +public final class WebFluxBudgetFilter implements WebFilter { + + private final WebRequestBudget budget; + private final BudgetProblemMapper problems; + private final JsonMapper mapper; + + /** + * A filter over one budget. + * + * @param budget the bounds to enforce + * @param problems renders a crossed bound as a problem document + * @param mapper serializes that document + */ + public WebFluxBudgetFilter( + WebRequestBudget budget, BudgetProblemMapper problems, JsonMapper mapper) { + this.budget = Objects.requireNonNull(budget, "budget"); + this.problems = Objects.requireNonNull(problems, "problems"); + this.mapper = Objects.requireNonNull(mapper, "mapper"); + } + + @Override + public Mono filter(ServerWebExchange exchange, WebFilterChain chain) { + try { + checkCheapDimensions(exchange); + } catch (WebBudgetExceededException exceeded) { + return writeProblem(exchange, exceeded); + } + return chain + .filter(new BoundedServerWebExchange(exchange, budget)) + .onErrorResume( + WebBudgetExceededException.class, + exceeded -> + exchange.getResponse().isCommitted() + // Nothing left to say: the status went out long ago. Propagating ends the + // response mid-document, which is the only signal left that it is incomplete. + ? Mono.error(exceeded) + : writeProblem(exchange, exceeded)); + } + + private void checkCheapDimensions(ServerWebExchange exchange) { + String path = exchange.getRequest().getURI().getRawPath(); + String query = exchange.getRequest().getURI().getRawQuery(); + int uriBytes = + path.getBytes(StandardCharsets.UTF_8).length + + (query == null ? 0 : query.getBytes(StandardCharsets.UTF_8).length + 1); + if (uriBytes > budget.maxUriBytes()) { + throw new WebBudgetExceededException( + WebBudgetViolation.URI_TOO_LONG, uriBytes, budget.maxUriBytes()); + } + + long headerBytes = 0; + for (Map.Entry> header : exchange.getRequest().getHeaders().headerSet()) { + for (String value : header.getValue()) { + headerBytes += header.getKey().length() + (value == null ? 0 : value.length()) + 4; + } + } + if (headerBytes > budget.maxHeaderBytes()) { + throw new WebBudgetExceededException( + WebBudgetViolation.HEADERS_TOO_LARGE, headerBytes, budget.maxHeaderBytes()); + } + + int parameters = exchange.getRequest().getQueryParams().size(); + if (parameters > budget.maxQueryParameters()) { + throw new WebBudgetExceededException( + WebBudgetViolation.TOO_MANY_QUERY_PARAMETERS, parameters, budget.maxQueryParameters()); + } + + new WebBudgetMeter(WebBudgetViolation.BODY_TOO_LARGE, budget.maxBodyBytes()) + .declared(exchange.getRequest().getHeaders().getContentLength()); + } + + private Mono writeProblem(ServerWebExchange exchange, WebBudgetExceededException exceeded) { + WebProblem problem = + problems.problemFor( + exceeded, URI.create(exchange.getRequest().getURI().getRawPath()), "0".repeat(32)); + exchange + .getResponse() + .setStatusCode(HttpStatusCode.valueOf(problems.statusFor(exceeded.violation()))); + exchange.getResponse().getHeaders().setContentType(MediaType.valueOf(WebProblem.MEDIA_TYPE)); + DataBuffer body = + exchange + .getResponse() + .bufferFactory() + .wrap(mapper.writeValueAsString(problem).getBytes(StandardCharsets.UTF_8)); + return exchange.getResponse().writeWith(Mono.just(body)); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/webflux/context/WebFluxRequestContextAccessor.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/webflux/context/WebFluxRequestContextAccessor.java new file mode 100644 index 00000000..d2ffecb9 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/webflux/context/WebFluxRequestContextAccessor.java @@ -0,0 +1,73 @@ +package dev.caskeleton.adapter.inbound.web.webflux.context; + +import dev.caskeleton.adapter.inbound.web.core.WebRequestContext; +import dev.caskeleton.adapter.inbound.web.evidence.WebExecutionEvidenceTracker; +import reactor.core.publisher.Mono; +import reactor.util.context.Context; +import reactor.util.context.ContextView; + +/** + * Reads and writes the request context in the Reactor Context. + * + *

The Reactor Context, never a {@code ThreadLocal}. A reactive chain hops threads between every + * operator, so a thread-local is not merely unreliable here — it is actively dangerous, because the + * value it returns belongs to whichever request last ran on that thread. An actor read from a + * thread-local on an event loop is an authorization decision made with somebody else's identity. + * + *

{@link #require} fails the request rather than substituting an anonymous actor. The design + * says so explicitly and the reason is the same: a context loss is a bug in the wiring, and + * answering it with "anonymous" turns that bug into a silent authorization change. + */ +public final class WebFluxRequestContextAccessor { + + /** The Reactor Context key the request context lives under. */ + public static final Class CONTEXT_KEY = WebRequestContext.class; + + /** The Reactor Context key the evidence tracker lives under. */ + public static final Class EVIDENCE_KEY = + WebExecutionEvidenceTracker.class; + + private WebFluxRequestContextAccessor() {} + + /** Puts a request context and its tracker into a Reactor Context. */ + public static Context write( + Context context, WebRequestContext requestContext, WebExecutionEvidenceTracker tracker) { + return context.put(CONTEXT_KEY, requestContext).put(EVIDENCE_KEY, tracker); + } + + /** + * The request context in the current chain. + * + *

Errors rather than completing empty. An empty {@code Mono} here would let a downstream + * {@code switchIfEmpty} quietly supply a default actor. + */ + public static Mono require() { + return Mono.deferContextual( + contextView -> + contextView.hasKey(CONTEXT_KEY) + ? Mono.just(contextView.get(CONTEXT_KEY)) + : Mono.error(contextLost())); + } + + /** The evidence tracker in the current chain. */ + public static Mono requireTracker() { + return Mono.deferContextual( + contextView -> + contextView.hasKey(EVIDENCE_KEY) + ? Mono.just(contextView.get(EVIDENCE_KEY)) + : Mono.error(contextLost())); + } + + /** The request context in a context view, when one is present. */ + public static java.util.Optional find(ContextView contextView) { + return contextView.hasKey(CONTEXT_KEY) + ? java.util.Optional.of(contextView.get(CONTEXT_KEY)) + : java.util.Optional.empty(); + } + + private static IllegalStateException contextLost() { + return new IllegalStateException( + "the web request context is not in the Reactor Context; substituting an anonymous actor" + + " here would turn a wiring bug into a silent authorization change"); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/webflux/context/WebFluxRequestContextFilter.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/webflux/context/WebFluxRequestContextFilter.java new file mode 100644 index 00000000..88a2668d --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/webflux/context/WebFluxRequestContextFilter.java @@ -0,0 +1,147 @@ +package dev.caskeleton.adapter.inbound.web.webflux.context; + +import dev.caskeleton.adapter.inbound.web.core.ActorContext; +import dev.caskeleton.adapter.inbound.web.core.ApiMajorVersion; +import dev.caskeleton.adapter.inbound.web.core.ExternalRequestContext; +import dev.caskeleton.adapter.inbound.web.core.TenantContext; +import dev.caskeleton.adapter.inbound.web.core.WebOperationName; +import dev.caskeleton.adapter.inbound.web.core.WebRequestContext; +import dev.caskeleton.adapter.inbound.web.core.WebRequestId; +import dev.caskeleton.adapter.inbound.web.core.WebTraceId; +import dev.caskeleton.adapter.inbound.web.evidence.WebExecutionEvidenceTracker; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.Locale; +import java.util.Objects; +import java.util.UUID; +import java.util.regex.Pattern; +import org.springframework.core.Ordered; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.WebFilter; +import org.springframework.web.server.WebFilterChain; +import reactor.core.publisher.Mono; + +/** + * Establishes the request context and evidence tracker for a reactive request. + * + *

Written into the Reactor Context rather than an exchange attribute or a thread-local, because + * the Reactor Context is the only one of the three that follows the chain across the thread hops a + * reactive pipeline makes. + * + *

The identifiers are validated before use for the same reason they are in the servlet filter: + * they reach log lines, and a newline in one splits a log entry so the caller writes the second + * half. + */ +public final class WebFluxRequestContextFilter implements WebFilter, Ordered { + + /** The header a client may propose a request id in. */ + public static final String REQUEST_ID_HEADER = "X-Request-Id"; + + /** The W3C trace context header. */ + public static final String TRACEPARENT_HEADER = "traceparent"; + + private static final Pattern SAFE_IDENTIFIER = Pattern.compile("[A-Za-z0-9._-]{1,128}"); + private static final Pattern TRACEPARENT = + Pattern.compile("[0-9a-f]{2}-([0-9a-f]{32})-[0-9a-f]{16}-[0-9a-f]{2}"); + + private final Clock clock; + private final Duration requestBudget; + private final boolean trustInboundRequestId; + private final int order; + + /** A filter with the platform defaults. */ + public WebFluxRequestContextFilter() { + this(Clock.systemUTC(), Duration.ofSeconds(10), false, Ordered.HIGHEST_PRECEDENCE + 10); + } + + /** + * A filter with explicit policy. + * + * @param clock the clock the deadline is computed from + * @param requestBudget how long a request may run + * @param trustInboundRequestId whether a caller may choose its own request id + * @param order the filter order + */ + public WebFluxRequestContextFilter( + Clock clock, Duration requestBudget, boolean trustInboundRequestId, int order) { + this.clock = Objects.requireNonNull(clock, "clock"); + this.requestBudget = Objects.requireNonNull(requestBudget, "requestBudget"); + this.trustInboundRequestId = trustInboundRequestId; + this.order = order; + } + + @Override + public Mono filter(ServerWebExchange exchange, WebFilterChain chain) { + ServerHttpRequest request = exchange.getRequest(); + Instant receivedAt = clock.instant(); + WebRequestId requestId = resolveRequestId(request); + WebRequestContext context = + new WebRequestContext( + requestId, + resolveTraceId(request), + operationName(request), + new ApiMajorVersion(1), + ActorContext.anonymous(), + TenantContext.none(), + Locale.ENGLISH, + receivedAt, + receivedAt.plus(requestBudget), + externalRequest(request)); + WebExecutionEvidenceTracker tracker = WebExecutionEvidenceTracker.received(); + + exchange.getResponse().getHeaders().set(REQUEST_ID_HEADER, requestId.value()); + return chain + .filter(exchange) + .contextWrite( + reactorContext -> + WebFluxRequestContextAccessor.write(reactorContext, context, tracker)); + } + + @Override + public int getOrder() { + return order; + } + + private WebRequestId resolveRequestId(ServerHttpRequest request) { + if (trustInboundRequestId) { + String proposed = request.getHeaders().getFirst(REQUEST_ID_HEADER); + if (proposed != null && SAFE_IDENTIFIER.matcher(proposed.trim()).matches()) { + return new WebRequestId(proposed.trim()); + } + } + return new WebRequestId(UUID.randomUUID().toString()); + } + + private WebTraceId resolveTraceId(ServerHttpRequest request) { + String traceparent = request.getHeaders().getFirst(TRACEPARENT_HEADER); + if (traceparent != null) { + var matcher = TRACEPARENT.matcher(traceparent.trim()); + if (matcher.matches()) { + return new WebTraceId(matcher.group(1)); + } + } + return new WebTraceId(UUID.randomUUID().toString().replace("-", "")); + } + + /** + * A low-cardinality operation name before routing has happened. + * + *

The method, not the path. A path here would be the raw URI, which carries identifiers and + * would make this the unbounded tag the whole naming rule exists to prevent; the routed operation + * name replaces it once a handler is selected. + */ + private WebOperationName operationName(ServerHttpRequest request) { + String method = request.getMethod().name().toLowerCase(Locale.ROOT); + return new WebOperationName("http." + method); + } + + private ExternalRequestContext externalRequest(ServerHttpRequest request) { + var uri = request.getURI(); + String scheme = uri.getScheme() == null ? "http" : uri.getScheme(); + int port = uri.getPort() > 0 ? uri.getPort() : ("https".equalsIgnoreCase(scheme) ? 443 : 80); + String host = uri.getHost() == null ? "localhost" : uri.getHost(); + return new ExternalRequestContext(scheme, host, port, ""); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/webflux/error/WebFluxProblemExceptionHandler.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/webflux/error/WebFluxProblemExceptionHandler.java new file mode 100644 index 00000000..e6233295 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/webflux/error/WebFluxProblemExceptionHandler.java @@ -0,0 +1,173 @@ +package dev.caskeleton.adapter.inbound.web.webflux.error; + +import dev.caskeleton.adapter.inbound.web.error.ProblemCode; +import dev.caskeleton.adapter.inbound.web.error.ValidationIssue; +import dev.caskeleton.adapter.inbound.web.error.WebProblem; +import dev.caskeleton.adapter.inbound.web.error.WebProblemFactory; +import dev.caskeleton.adapter.inbound.web.error.WebResourceNotVisibleException; +import dev.caskeleton.adapter.inbound.web.validation.WebValidationExceptionMapper; +import java.net.URI; +import java.util.List; +import java.util.Objects; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.bind.support.WebExchangeBindException; +import org.springframework.web.server.MethodNotAllowedException; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.ServerWebInputException; +import org.springframework.web.server.UnsupportedMediaTypeStatusException; + +/** + * The reactive twin, mapping the framework's failures to the same catalog. + * + *

A twin rather than a shared class because the exception types genuinely differ: WebFlux raises + * {@code WebExchangeBindException} where MVC raises {@code MethodArgumentNotValidException}, and + * {@code ServerWebInputException} where MVC raises {@code HttpMessageNotReadableException}. There + * is no common supertype that means "the body would not bind". + * + *

What is shared is everything that decides the answer: the catalog, the validation mapper, and + * the codes. So the two handlers cannot disagree about what a failure is called or what status it + * carries — only about which framework exception led there. {@code CrossStackParityTest} is what + * keeps that promise honest. + */ +@RestControllerAdvice +// Mirrors every condition on the auto-configuration that supplies its WebProblemFactory. Only +// half of them was not enough: the all-off deployment is not a reactive application at all, so the +// factory is absent while the property condition still matched, and the context failed to start on +// an unsatisfied dependency. +@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.REACTIVE) +// Gated on the same property as the platform auto-configuration that supplies its +// WebProblemFactory, +// so the handler and its dependency appear and disappear together. +// +// Not @ConditionalOnBean: that condition is only reliable inside an auto-configuration class, where +// Boot controls the evaluation order. On a component-scanned type it is evaluated against whatever +// happens to be registered at that moment, and this handler silently vanished from every fixture +// that declared the factory in the very same configuration. +@ConditionalOnProperty( + prefix = "backend.web.webflux", + name = "enabled", + havingValue = "true", + matchIfMissing = true) +@Order(Ordered.HIGHEST_PRECEDENCE + 10) +public class WebFluxProblemExceptionHandler { + + private final WebProblemFactory problems; + private final WebValidationExceptionMapper validationMapper; + + /** + * A handler over the problem catalog. + * + * @param problems the problem document factory + */ + public WebFluxProblemExceptionHandler(WebProblemFactory problems) { + this.problems = Objects.requireNonNull(problems, "problems"); + this.validationMapper = new WebValidationExceptionMapper(); + } + + /** A bound request that violated a declared constraint: 422. */ + @ExceptionHandler(WebExchangeBindException.class) + public ResponseEntity handleValidation( + WebExchangeBindException failure, ServerWebExchange exchange) { + List issues = + failure.getFieldErrors().stream() + .map( + error -> + new ValidationIssue( + "/" + error.getField().replace('.', '/'), + error.getCode() == null ? "INVALID" : error.getCode(), + error.getDefaultMessage() == null + ? "is invalid" + : error.getDefaultMessage())) + .toList(); + return answer(ProblemCode.VALIDATION_FAILED, "the request failed validation", exchange, issues); + } + + /** + * A document that could not be read or could not be bound: 400. + * + *

Declared after the bind handler because {@code WebExchangeBindException} extends {@code + * ServerWebInputException}; without the more specific handler present, every validation failure + * would land here and be published as 400 with the wrong code. + */ + @ExceptionHandler(ServerWebInputException.class) + public ResponseEntity handleUnreadable( + ServerWebInputException failure, ServerWebExchange exchange) { + ProblemCode code = + validationMapper.find(failure.getMostSpecificCause()).orElse(ProblemCode.MALFORMED_REQUEST); + return answer(code, "the request body could not be read", exchange, List.of()); + } + + /** A media type this operation does not read: 415. */ + @ExceptionHandler(UnsupportedMediaTypeStatusException.class) + public ResponseEntity handleMediaType( + UnsupportedMediaTypeStatusException failure, ServerWebExchange exchange) { + return answer( + ProblemCode.UNSUPPORTED_MEDIA_TYPE, + "this operation does not read that media type", + exchange, + List.of()); + } + + /** A method this resource does not serve: 405. */ + @ExceptionHandler(MethodNotAllowedException.class) + public ResponseEntity handleMethod( + MethodNotAllowedException failure, ServerWebExchange exchange) { + WebProblem problem = + problems.create( + ProblemCode.METHOD_NOT_ALLOWED, + "this resource does not serve that method", + URI.create(exchange.getRequest().getURI().getRawPath()), + traceIdOf(exchange), + List.of()); + ResponseEntity.BodyBuilder builder = + ResponseEntity.status(problem.status()) + .contentType(MediaType.valueOf(WebProblem.MEDIA_TYPE)); + if (!failure.getSupportedMethods().isEmpty()) { + // RFC 9110 requires Allow on a 405, and the servlet handler sets it too. A header present on + // one stack and absent on the other is precisely what the parity recording catches. + builder.header( + "Allow", + failure.getSupportedMethods().stream() + .map(Object::toString) + .collect(java.util.stream.Collectors.joining(", "))); + } + return builder.body(problem); + } + + /** The resource is absent, or the caller may not know it exists: 404 either way. */ + @ExceptionHandler(WebResourceNotVisibleException.class) + public ResponseEntity handleNotVisible( + WebResourceNotVisibleException failure, ServerWebExchange exchange) { + return answer( + ProblemCode.RESOURCE_NOT_FOUND, + "no such " + failure.resourceKind() + " is visible", + exchange, + List.of()); + } + + private ResponseEntity answer( + ProblemCode code, String detail, ServerWebExchange exchange, List issues) { + WebProblem problem = + problems.create( + code, + detail, + URI.create(exchange.getRequest().getURI().getRawPath()), + traceIdOf(exchange), + issues); + return ResponseEntity.status(problem.status()) + .contentType(MediaType.valueOf(WebProblem.MEDIA_TYPE)) + .body(problem); + } + + private static String traceIdOf(ServerWebExchange exchange) { + Object traceId = exchange.getAttributes().get("webTraceId"); + return traceId == null ? "0".repeat(32) : traceId.toString(); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/webflux/guard/BlockingCallDetectedException.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/webflux/guard/BlockingCallDetectedException.java new file mode 100644 index 00000000..1afebbb6 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/webflux/guard/BlockingCallDetectedException.java @@ -0,0 +1,26 @@ +package dev.caskeleton.adapter.inbound.web.webflux.guard; + +/** + * A blocking dependency was called from an event-loop thread. + * + *

Thrown rather than logged, and that is the decision worth defending. A blocking call on an + * event loop does not fail — it succeeds, slowly, while holding one of a handful of threads that + * serve every connection the process has. The symptom is a service that is fine under test and + * collapses under concurrency, and by then the cause is several layers away from the stack trace. + * + *

Failing the one request that did it turns an invisible capacity bug into a visible defect on + * the change that introduced it. + */ +public final class BlockingCallDetectedException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + /** + * Creates the failure. + * + * @param message which dependency was called and on which thread + */ + public BlockingCallDetectedException(String message) { + super(message); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/webflux/guard/BlockingDependencyGuard.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/webflux/guard/BlockingDependencyGuard.java new file mode 100644 index 00000000..b467ec7a --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/webflux/guard/BlockingDependencyGuard.java @@ -0,0 +1,79 @@ +package dev.caskeleton.adapter.inbound.web.webflux.guard; + +import java.util.Objects; +import java.util.function.Predicate; + +/** + * Refuses a registered blocking dependency when it is reached from an event-loop thread. + * + *

The guard exists because the mistake is silent. Calling a blocking JPA repository from a + * WebFlux handler works: the query returns, the response is written, the test passes. What it also + * does is occupy one of {@code 2 * cores} threads that serve every connection in the process, so + * the service degrades under concurrency in a way that no single request reproduces. + * + *

Thread identity is a predicate rather than a hard-coded name so the same guard covers Reactor + * Netty's {@code reactor-http-nio-*}, a custom loop group and a test harness. Matching on names is + * itself a compromise — there is no portable "am I on an event loop" question to ask — and the + * predicate is the seam that keeps that compromise in one place. + * + *

The Stable profile has no escape hatch. The design puts the controlled blocking bridge in the + * Advanced module, because a bridge available by default is one that gets used by default. + */ +public final class BlockingDependencyGuard { + + /** + * Reactor Netty's event-loop threads, which is the Stable server baseline. + * + *

Three prefixes because the name is not stable across versions. Spring Boot names the server + * loop group {@code webflux-http-*}; a standalone Reactor Netty client or server uses {@code + * reactor-http-*} and {@code reactor-tcp-*}. The first version of this predicate matched only the + * {@code reactor-} forms, so on the actual Stable runtime the guard never fired — a control that + * exists, has a passing unit test, and is reached by nothing. The real-container gate is what + * found it, which is the argument for having that gate. + */ + public static final Predicate REACTOR_NETTY_EVENT_LOOP = + name -> + name != null + && (name.startsWith("reactor-http-") + || name.startsWith("reactor-tcp-") + || name.startsWith("webflux-http-")); + + private final Predicate eventLoopThread; + + /** + * A guard for a specific notion of "event-loop thread". + * + * @param eventLoopThread decides, from a thread name, whether blocking there is forbidden + */ + public BlockingDependencyGuard(Predicate eventLoopThread) { + this.eventLoopThread = Objects.requireNonNull(eventLoopThread, "eventLoopThread"); + } + + /** A guard for the Stable Reactor Netty baseline. */ + public static BlockingDependencyGuard reactorNetty() { + return new BlockingDependencyGuard(REACTOR_NETTY_EVENT_LOOP); + } + + /** + * Refuses the call when the current thread is an event loop. + * + * @param dependency what was about to be called, named for the failure message + * @throws BlockingCallDetectedException when the current thread must not block + */ + public void check(String dependency) { + String threadName = Thread.currentThread().getName(); + if (eventLoopThread.test(threadName)) { + throw new BlockingCallDetectedException( + "blocking dependency '" + + dependency + + "' was called on event-loop thread '" + + threadName + + "'; this succeeds slowly while holding a thread that serves every connection"); + } + } + + /** Whether the current thread is one this guard forbids blocking on. */ + public boolean onEventLoop() { + return eventLoopThread.test(Thread.currentThread().getName()); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/webflux/idempotency/WebFluxIdempotentInvoker.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/webflux/idempotency/WebFluxIdempotentInvoker.java new file mode 100644 index 00000000..8129f089 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/webflux/idempotency/WebFluxIdempotentInvoker.java @@ -0,0 +1,168 @@ +package dev.caskeleton.adapter.inbound.web.webflux.idempotency; + +import dev.caskeleton.adapter.inbound.web.core.WebRequestContext; +import dev.caskeleton.adapter.inbound.web.error.WebProblem; +import dev.caskeleton.adapter.inbound.web.error.WebProblemFactory; +import dev.caskeleton.adapter.inbound.web.http.ApiHeaders; +import dev.caskeleton.adapter.inbound.web.idempotency.IdempotencyAdmission; +import dev.caskeleton.adapter.inbound.web.idempotency.IdempotencyResponsePlan; +import dev.caskeleton.adapter.inbound.web.idempotency.WebIdempotencyGate; +import dev.caskeleton.adapter.inbound.web.operation.WebOperationProfile; +import java.net.URI; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Scheduler; +import tools.jackson.databind.json.JsonMapper; + +/** + * The reactive counterpart of the MVC invoker. + * + *

It shares the decision — {@link WebIdempotencyGate} and {@link IdempotencyResponsePlan} — and + * differs only where the two stacks genuinely differ: reading headers from an exchange, and keeping + * the blocking store off the event loop. + * + *

That second difference is the reason this class exists at all rather than the MVC one being + * reused. {@code IdempotencyStorePort} is backed by JDBC in this repository, and a JDBC call on a + * Reactor event-loop thread stalls every other request that loop is carrying. Every store call here + * is therefore pushed to a bounded-elastic scheduler, which is also what {@code + * BlockingDependencyGuard} asserts in the WebFlux contract lane. + */ +public final class WebFluxIdempotentInvoker { + + private final WebIdempotencyGate gate; + private final JsonMapper mapper; + private final WebProblemFactory problems; + private final Scheduler blockingScheduler; + + /** + * An invoker over one gate. + * + * @param gate the transport-neutral admission decision + * @param mapper the platform's strict mapper + * @param problems the problem document factory + * @param blockingScheduler where the blocking store is allowed to run + */ + public WebFluxIdempotentInvoker( + WebIdempotencyGate gate, + JsonMapper mapper, + WebProblemFactory problems, + Scheduler blockingScheduler) { + this.gate = Objects.requireNonNull(gate, "gate"); + this.mapper = Objects.requireNonNull(mapper, "mapper"); + this.problems = Objects.requireNonNull(problems, "problems"); + this.blockingScheduler = Objects.requireNonNull(blockingScheduler, "blockingScheduler"); + } + + /** + * Invokes the handler once per key. + * + * @param exchange the reactive exchange, read for headers only + * @param context the resolved request context + * @param profile the operation's declared profile + * @param pathVariables the identifiers the path named + * @param command the bound request model + * @param handler the operation itself + * @param successStatus the status a fresh success is answered with + */ + public Mono> invoke( + ServerWebExchange exchange, + WebRequestContext context, + WebOperationProfile profile, + Map pathVariables, + Object command, + Mono handler, + int successStatus) { + Objects.requireNonNull(exchange, "exchange"); + Objects.requireNonNull(context, "context"); + Objects.requireNonNull(profile, "profile"); + Objects.requireNonNull(handler, "handler"); + + String rawKey = exchange.getRequest().getHeaders().getFirst(ApiHeaders.IDEMPOTENCY_KEY); + String principal = context.actor().subject(); + String tenantId = context.tenant().value().orElse(null); + Map headers = headersOf(exchange); + + return Mono.fromCallable( + () -> + gate.admit( + profile.idempotency(), + profile.operationName(), + principal, + tenantId, + rawKey, + pathVariables, + command, + headers)) + .subscribeOn(blockingScheduler) + .flatMap( + admission -> { + IdempotencyResponsePlan plan = IdempotencyResponsePlan.of(admission, successStatus); + return plan.runOperation() + ? run(handler, admission, profile, principal, tenantId, rawKey, successStatus) + : Mono.just(answer(plan, context)); + }); + } + + private Mono> run( + Mono handler, + IdempotencyAdmission admission, + WebOperationProfile profile, + String principal, + String tenantId, + String rawKey, + int successStatus) { + return handler + .map(mapper::writeValueAsString) + .flatMap( + payload -> + admission.outcome() == IdempotencyAdmission.Outcome.PROCEED + ? Mono.fromRunnable( + () -> + gate.complete( + profile.operationName(), principal, tenantId, rawKey, payload)) + .subscribeOn(blockingScheduler) + .thenReturn(payload) + : Mono.just(payload)) + .map( + payload -> + ResponseEntity.status(successStatus) + .contentType(MediaType.APPLICATION_JSON) + .body(payload)); + } + + private ResponseEntity answer(IdempotencyResponsePlan plan, WebRequestContext context) { + if (plan.replayed()) { + return ResponseEntity.status(plan.status()) + .contentType(MediaType.APPLICATION_JSON) + .header(IdempotencyResponsePlan.REPLAYED_HEADER, "true") + .body(plan.replayPayload().orElseThrow()); + } + WebProblem problem = + problems.create( + plan.problemCode().orElseThrow(), + plan.status() == 409 + ? "an earlier attempt with this idempotency key is still running" + : "this idempotency key was already used for a different request", + URI.create("/requests/" + context.requestId().value()), + context.traceId().value(), + List.of()); + problems.requireStatusAgreement(plan.status(), problem); + ResponseEntity.BodyBuilder builder = + ResponseEntity.status(plan.status()).contentType(MediaType.valueOf(WebProblem.MEDIA_TYPE)); + if (plan.status() == 409) { + builder.header( + IdempotencyResponsePlan.RETRY_AFTER_HEADER, + Integer.toString(IdempotencyResponsePlan.IN_PROGRESS_RETRY_AFTER_SECONDS)); + } + return builder.body(mapper.writeValueAsString(problem)); + } + + private static Map headersOf(ServerWebExchange exchange) { + return exchange.getRequest().getHeaders().toSingleValueMap(); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/webflux/operation/ReactiveOperationHttpController.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/webflux/operation/ReactiveOperationHttpController.java new file mode 100644 index 00000000..23ad8ee4 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/webflux/operation/ReactiveOperationHttpController.java @@ -0,0 +1,106 @@ +package dev.caskeleton.adapter.inbound.web.webflux.operation; + +import dev.caskeleton.adapter.inbound.web.core.WebRequestContext; +import dev.caskeleton.adapter.inbound.web.error.WebResourceNotVisibleException; +import dev.caskeleton.adapter.inbound.web.operationasync.OperationId; +import dev.caskeleton.adapter.inbound.web.operationasync.OperationQueryService; +import dev.caskeleton.adapter.inbound.web.operationasync.OperationResource; +import dev.caskeleton.adapter.inbound.web.operationasync.OperationResponse; +import dev.caskeleton.adapter.inbound.web.operationasync.OperationResponseMapper; +import dev.caskeleton.adapter.inbound.web.operationasync.OperationStatus; +import java.util.Objects; +import java.util.Optional; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Scheduler; + +/** + * The polled operation resource, on the reactive stack. + * + *

Same route, same statuses, same disclosure rule as the servlet controller — those come from + * {@link OperationQueryService}, which both call. What differs is only what has to: the store is + * blocking, so every read is pushed off the event loop. + */ +@RestController +// Gated, because the durable operation store only exists when a persistence adapter provides one. +// Without the gate this controller is component-scanned into every deployment and the context +// fails to start with an unsatisfied dependency — which is exactly what happened, and what the +// all-adapters-off startup contract caught. +@ConditionalOnProperty( + prefix = "app.web-platform.durable-operations", + name = "enabled", + havingValue = "true") +@RequestMapping(ReactiveOperationHttpController.BASE_PATH) +public class ReactiveOperationHttpController { + + /** Where operations are published. */ + public static final String BASE_PATH = "/api/v1/operations"; + + private final OperationQueryService operations; + private final Scheduler blockingScheduler; + + /** + * A controller over the query service. + * + * @param operations reads and cancels operations + * @param blockingScheduler where the blocking store is allowed to run + */ + public ReactiveOperationHttpController( + OperationQueryService operations, Scheduler blockingScheduler) { + this.operations = Objects.requireNonNull(operations, "operations"); + this.blockingScheduler = Objects.requireNonNull(blockingScheduler, "blockingScheduler"); + } + + /** Reads one operation. */ + @GetMapping(path = "/{operationId}", produces = MediaType.APPLICATION_JSON_VALUE) + public Mono> get( + @PathVariable String operationId, WebRequestContext context) { + return Mono.fromCallable(() -> operations.find(new OperationId(operationId), context)) + .subscribeOn(blockingScheduler) + .map(ReactiveOperationHttpController::answer); + } + + /** Requests cancellation. */ + @DeleteMapping("/{operationId}") + public Mono> cancel( + @PathVariable String operationId, WebRequestContext context) { + return Mono.fromCallable(() -> operations.cancel(new OperationId(operationId), context)) + .subscribeOn(blockingScheduler) + .map(outcome -> cancelAnswer(outcome.isPresent(), operationId)); + } + + private static ResponseEntity cancelAnswer(boolean visible, String operationId) { + if (!visible) { + throw new WebResourceNotVisibleException("operation"); + } + // A second cancel, and a cancel of work that already finished, both land here. 409 would + // invite a retry that can never succeed; 202 says the same true thing every time. + return ResponseEntity.accepted().header("Location", BASE_PATH + "/" + operationId).build(); + } + + private static ResponseEntity answer(Optional resource) { + if (resource.isEmpty()) { + // Thrown rather than returned, for the same reason as on the servlet side: a bare 404 is a + // failure with nothing in it for a client to branch on. + throw new WebResourceNotVisibleException("operation"); + } + OperationResource operation = resource.get(); + ResponseEntity.BodyBuilder builder = ResponseEntity.ok(); + operation + .retryAfter() + .ifPresent(after -> builder.header("Retry-After", Long.toString(after.toSeconds()))); + if (operation.status() == OperationStatus.SUCCEEDED) { + operation + .resultLocation() + .ifPresent(location -> builder.header("Content-Location", location.toString())); + } + return builder.body(OperationResponseMapper.from(operation)); + } +} diff --git a/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/webflux/throttle/WebFluxThrottleFilter.java b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/webflux/throttle/WebFluxThrottleFilter.java new file mode 100644 index 00000000..58b92107 --- /dev/null +++ b/src/adapter/inbound/web/src/main/java/dev/caskeleton/adapter/inbound/web/webflux/throttle/WebFluxThrottleFilter.java @@ -0,0 +1,142 @@ +package dev.caskeleton.adapter.inbound.web.webflux.throttle; + +import dev.caskeleton.adapter.inbound.web.admission.AdmissionDecision; +import dev.caskeleton.adapter.inbound.web.admission.AdmissionPermit; +import dev.caskeleton.adapter.inbound.web.admission.WebAdmissionController; +import dev.caskeleton.adapter.inbound.web.core.WebRequestContext; +import dev.caskeleton.adapter.inbound.web.error.ThrottleProblemWriter; +import dev.caskeleton.adapter.inbound.web.error.WebProblem; +import dev.caskeleton.adapter.inbound.web.operation.WebOperationProfile; +import dev.caskeleton.adapter.inbound.web.ratelimit.RateLimitDecision; +import dev.caskeleton.adapter.inbound.web.ratelimit.RateLimitFailurePolicy; +import dev.caskeleton.adapter.inbound.web.ratelimit.RateLimitProfileName; +import dev.caskeleton.adapter.inbound.web.ratelimit.WebRateLimiter; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Objects; +import java.util.function.Function; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.MediaType; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.WebFilter; +import org.springframework.web.server.WebFilterChain; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Scheduler; +import tools.jackson.databind.json.JsonMapper; + +/** + * The reactive quota-then-capacity filter. + * + *

Same order and same answers as the servlet one; two things differ and both are forced by the + * stack. + * + *

The limiter is a network call, so it runs on the blocking scheduler rather than the event + * loop. And the admission permit is released in {@code doFinally} rather than a try-with-resources: + * the request is not finished when {@code filter} returns, it is finished when the returned + * publisher terminates. Releasing at the end of the method would hand the slot back while the + * request is still running, and the concurrency bound would mean nothing at all — a mistake that is + * invisible until the service is under enough load for it to matter. + */ +public final class WebFluxThrottleFilter implements WebFilter { + + private final WebRateLimiter limiter; + private final WebAdmissionController admission; + private final Function profiles; + private final Function contexts; + private final RateLimitFailurePolicy failurePolicy; + private final ThrottleProblemWriter problems; + private final JsonMapper mapper; + private final Scheduler blockingScheduler; + + /** + * A filter over one limiter and one admission controller. + * + * @param limiter the caller quota + * @param admission the service capacity + * @param profiles resolves the operation profile for an exchange + * @param contexts resolves the request context + * @param failurePolicy what to do when the limiter's store is unreachable + * @param problems renders the refusals + * @param mapper serializes them + * @param blockingScheduler where the limiter is allowed to block + */ + public WebFluxThrottleFilter( + WebRateLimiter limiter, + WebAdmissionController admission, + Function profiles, + Function contexts, + RateLimitFailurePolicy failurePolicy, + ThrottleProblemWriter problems, + JsonMapper mapper, + Scheduler blockingScheduler) { + this.limiter = Objects.requireNonNull(limiter, "limiter"); + this.admission = Objects.requireNonNull(admission, "admission"); + this.profiles = Objects.requireNonNull(profiles, "profiles"); + this.contexts = Objects.requireNonNull(contexts, "contexts"); + this.failurePolicy = Objects.requireNonNull(failurePolicy, "failurePolicy"); + this.problems = Objects.requireNonNull(problems, "problems"); + this.mapper = Objects.requireNonNull(mapper, "mapper"); + this.blockingScheduler = Objects.requireNonNull(blockingScheduler, "blockingScheduler"); + } + + @Override + public Mono filter(ServerWebExchange exchange, WebFilterChain chain) { + WebOperationProfile profile = profiles.apply(exchange); + WebRequestContext context = contexts.apply(exchange); + + return Mono.fromCallable(() -> evaluateQuota(context)) + .subscribeOn(blockingScheduler) + .flatMap( + quota -> { + if (!quota.allowed()) { + return write( + exchange, + problems.quotaStatus(), + problems.quotaExhausted( + URI.create(exchange.getRequest().getURI().getRawPath()), + context.traceId().value()), + quota.retryAfter().orElseThrow()); + } + AdmissionDecision admitted = admission.admit(profile.admission()); + if (!admitted.admitted()) { + return write( + exchange, + problems.capacityStatus(), + problems.capacityExhausted( + URI.create(exchange.getRequest().getURI().getRawPath()), + context.traceId().value()), + admitted.retryAfter().orElseThrow()); + } + AdmissionPermit permit = admitted.permit().orElseThrow(); + return chain.filter(exchange).doFinally(signal -> permit.close()); + }); + } + + private RateLimitDecision evaluateQuota(WebRequestContext context) { + try { + return limiter.evaluate(context, RateLimitProfileName.standard()); + } catch (RuntimeException limiterUnavailable) { + return failurePolicy == RateLimitFailurePolicy.FAIL_CLOSED + ? RateLimitDecision.refused(1, context.receivedAt().plusSeconds(1), Duration.ofSeconds(1)) + : RateLimitDecision.allowed(1, 1, context.receivedAt().plusSeconds(1)); + } + } + + private Mono write( + ServerWebExchange exchange, int status, WebProblem problem, Duration retryAfter) { + exchange.getResponse().setStatusCode(HttpStatusCode.valueOf(status)); + exchange.getResponse().getHeaders().setContentType(MediaType.valueOf(WebProblem.MEDIA_TYPE)); + exchange + .getResponse() + .getHeaders() + .set("Retry-After", ThrottleProblemWriter.retryAfterSeconds(retryAfter)); + DataBuffer body = + exchange + .getResponse() + .bufferFactory() + .wrap(mapper.writeValueAsString(problem).getBytes(StandardCharsets.UTF_8)); + return exchange.getResponse().writeWith(Mono.just(body)); + } +} diff --git a/src/adapter/inbound/web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/src/adapter/inbound/web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000..447164ed --- /dev/null +++ b/src/adapter/inbound/web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,2 @@ +dev.caskeleton.adapter.inbound.web.mvc.autoconfigure.WebMvcPlatformAutoConfiguration +dev.caskeleton.adapter.inbound.web.webflux.autoconfigure.WebFluxPlatformAutoConfiguration diff --git a/src/adapter/inbound/web/src/main/resources/META-INF/web/problem-catalog.yaml b/src/adapter/inbound/web/src/main/resources/META-INF/web/problem-catalog.yaml new file mode 100644 index 00000000..f5d40ef0 --- /dev/null +++ b/src/adapter/inbound/web/src/main/resources/META-INF/web/problem-catalog.yaml @@ -0,0 +1,99 @@ +# The published problem catalog: every failure this API reports, with its stable type URI and the +# HTTP status it is always answered with. +# +# This is the rendering of ProblemCatalog.standard(). ProblemCatalogTest asserts the two agree, +# because a client author reads this file and a document that drifts from the code is quoted as if +# it were true. +# +# DEPENDENCY_FAILURE is 502 and DEPENDENCY_TIMEOUT is 504 rather than either being chosen per call: +# a 504 tells a caller the request may still be running behind the gateway, and a 502 tells it the +# request is not. +problems: + MALFORMED_REQUEST: + type: https://problems.caskeleton.dev/web/malformed-request + title: Malformed request + status: 400 + BINDING_FAILED: + type: https://problems.caskeleton.dev/web/binding-failed + title: Request could not be bound + status: 400 + VALIDATION_FAILED: + type: https://problems.caskeleton.dev/web/validation-failed + title: Request failed validation + status: 422 + AUTHENTICATION_REQUIRED: + type: https://problems.caskeleton.dev/web/authentication-required + title: Authentication required + status: 401 + ACCESS_DENIED: + type: https://problems.caskeleton.dev/web/access-denied + title: Access denied + status: 403 + RESOURCE_NOT_FOUND: + type: https://problems.caskeleton.dev/web/resource-not-found + title: Resource not found + status: 404 + RESOURCE_CONFLICT: + type: https://problems.caskeleton.dev/web/resource-conflict + title: Resource conflict + status: 409 + PRECONDITION_FAILED: + type: https://problems.caskeleton.dev/web/precondition-failed + title: Precondition failed + status: 412 + IDEMPOTENCY_KEY_REQUIRED: + type: https://problems.caskeleton.dev/web/idempotency-key-required + title: Idempotency key required + status: 400 + IDEMPOTENCY_KEY_REUSED: + type: https://problems.caskeleton.dev/web/idempotency-key-reused + title: Idempotency key reused + status: 422 + IDEMPOTENCY_REQUEST_IN_PROGRESS: + type: https://problems.caskeleton.dev/web/idempotency-request-in-progress + title: Request already in progress + status: 409 + RATE_LIMITED: + type: https://problems.caskeleton.dev/web/rate-limited + title: Rate limit exceeded + status: 429 + ADMISSION_REJECTED: + type: https://problems.caskeleton.dev/web/admission-rejected + title: Service is shedding load + status: 503 + DEPENDENCY_FAILURE: + type: https://problems.caskeleton.dev/web/dependency-failure + title: A dependency failed + status: 502 + DEPENDENCY_TIMEOUT: + type: https://problems.caskeleton.dev/web/dependency-timeout + title: A dependency timed out + status: 504 + METHOD_NOT_ALLOWED: + type: https://problems.caskeleton.dev/web/method-not-allowed + title: Method not allowed + status: 405 + NOT_ACCEPTABLE: + type: https://problems.caskeleton.dev/web/not-acceptable + title: No acceptable representation + status: 406 + RESOURCE_GONE: + type: https://problems.caskeleton.dev/web/resource-gone + title: Resource is gone + status: 410 + REQUEST_TOO_LARGE: + type: https://problems.caskeleton.dev/web/request-too-large + title: Request exceeds a size bound + status: 413 + UNSUPPORTED_MEDIA_TYPE: + type: https://problems.caskeleton.dev/web/unsupported-media-type + title: Unsupported media type + status: 415 + RESPONSE_TOO_LARGE: + type: https://problems.caskeleton.dev/web/response-too-large + title: Response exceeds a size bound + status: 500 + INTERNAL_ERROR: + type: https://problems.caskeleton.dev/web/internal-error + title: Internal error + status: 500 diff --git a/src/adapter/inbound/web/src/main/resources/META-INF/web/wire-type-manifest.yaml b/src/adapter/inbound/web/src/main/resources/META-INF/web/wire-type-manifest.yaml new file mode 100644 index 00000000..4b59e1d2 --- /dev/null +++ b/src/adapter/inbound/web/src/main/resources/META-INF/web/wire-type-manifest.yaml @@ -0,0 +1,39 @@ +# The declared JSON representation of every type whose default rendering is wrong for a public API. +# +# This file is the human-readable rendering of WebWireTypeManifest.standard(). It is committed so a +# client author can read the contract without reading Java, and WebWireTypeManifestTest asserts the +# two agree — a document that drifts from the code is worse than no document, because it is quoted. +# +# `long` is a string on purpose: beyond 2^53 a JSON number is silently rounded by a JavaScript +# client, so the identifier it echoes back is not the one it was sent. +wire-types: + INSTANT: + wire-format: RFC3339_UTC + nullable: false + OFFSET_DATE_TIME: + wire-format: RFC3339_OFFSET + nullable: false + LOCAL_DATE: + wire-format: ISO8601_DATE + nullable: false + DURATION: + wire-format: ISO8601_DURATION + nullable: false + UUID: + wire-format: RFC9562_STRING + nullable: false + BIG_DECIMAL: + wire-format: DECIMAL_STRING + nullable: false + LONG: + wire-format: INTEGER_STRING + nullable: false + ENUM: + wire-format: DECLARED_WIRE_VALUE + nullable: false + URI: + wire-format: RFC3986_STRING + nullable: false + LOCALE: + wire-format: BCP47_TAG + nullable: false diff --git a/src/adapter/inbound/web/src/nginxProxyTest/java/dev/caskeleton/adapter/inbound/web/testkit/proxy/NginxProxyContractIT.java b/src/adapter/inbound/web/src/nginxProxyTest/java/dev/caskeleton/adapter/inbound/web/testkit/proxy/NginxProxyContractIT.java new file mode 100644 index 00000000..7960ff4e --- /dev/null +++ b/src/adapter/inbound/web/src/nginxProxyTest/java/dev/caskeleton/adapter/inbound/web/testkit/proxy/NginxProxyContractIT.java @@ -0,0 +1,186 @@ +package dev.caskeleton.adapter.inbound.web.testkit.proxy; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Map; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.test.context.ActiveProfiles; +import org.testcontainers.junit.jupiter.Testcontainers; + +/** + * The platform behind a real reverse proxy. + * + *

Everything here is about the seam between two systems, and the seam is exactly what a unit + * test of either side cannot reach. The application's forwarded-header handling has thorough unit + * coverage and it is all conditional on what the proxy actually sends; the proxy configuration is a + * file nothing verifies. A spoofed host that the application correctly refuses is worthless if the + * proxy passes the client's value through under a header name the application trusts, and no test + * on either side alone would notice. + */ +@SpringBootTest( + classes = ProxyFixtureApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("web-contract") +@Testcontainers +class NginxProxyContractIT { + + private static NginxProxyHarness proxy; + + @LocalServerPort private int port; + + @BeforeAll + static void requireDocker() { + // Stated rather than skipped. A lane that quietly passes without a container runtime has been + // certifying nothing since whenever Docker last broke, and nobody finds out. + org.junit.jupiter.api.Assertions.assertTrue( + NginxProxyHarness.dockerAvailable(), + "this lane needs a container runtime; it certifies Nginx's own behaviour and cannot be" + + " simulated"); + } + + @AfterAll + static void stopProxy() { + if (proxy != null) { + proxy.close(); + proxy = null; + } + } + + private NginxProxyHarness proxy() { + if (proxy == null) { + proxy = new NginxProxyHarness(port); + } + return proxy; + } + + @Test + @DisplayName("an attacker cannot override the forwarded host") + void attackerCannotOverrideForwardedHost() { + // The header is set by the proxy, not added to. Appending would leave the application to pick + // between two values, and the conventional pick is the first — the attacker's. + NginxProxyHarness.Response response = + proxy() + .get( + "/dev-api" + ProxyFixtureController.EXTERNAL_URI_PATH, + Map.of("X-Forwarded-Host", "evil.example")); + + assertThat(response.status()).isEqualTo(200); + assertThat(response.body()).startsWith("https://hyeonworks.com"); + assertThat(response.body()).doesNotContain("evil.example"); + } + + @Test + @DisplayName("an attacker cannot override the forwarded scheme") + void attackerCannotDowngradeTheForwardedScheme() { + // A downgraded scheme is how an absolute link in a Location header becomes plaintext, and the + // browser follows it before anything notices. + NginxProxyHarness.Response response = + proxy() + .get( + "/api" + ProxyFixtureController.EXTERNAL_URI_PATH, + Map.of("X-Forwarded-Proto", "http")); + + assertThat(response.body()).startsWith("https://"); + } + + @Test + @DisplayName("an attacker cannot forge the client address") + void attackerCannotForgeTheClientAddress() { + // The address the rate limiter and the audit log key on. A client that could set it would be + // able to spend somebody else's quota and sign somebody else's actions. + NginxProxyHarness.Response response = + proxy() + .get( + "/api" + ProxyFixtureController.CLIENT_ADDRESS_PATH, + Map.of("X-Forwarded-For", "203.0.113.9")); + + assertThat(response.body()).isNotEqualTo("203.0.113.9"); + } + + @Test + @DisplayName("a client-sent RFC 7239 Forwarded header is cleared") + void standardForwardedHeaderIsCleared() { + // The header everyone forgets. A framework that prefers RFC 7239 over the X- headers would + // take the client's word over the proxy's, and every X-Forwarded-* rule above would be intact + // and irrelevant. + NginxProxyHarness.Response response = + proxy() + .get( + "/api" + ProxyFixtureController.EXTERNAL_URI_PATH, + Map.of("Forwarded", "host=evil.example;proto=http")); + + assertThat(response.body()).startsWith("https://hyeonworks.com"); + assertThat(response.body()).doesNotContain("evil.example"); + } + + @Test + @DisplayName("each public prefix maps to the application path exactly once") + void prefixIsAppliedExactlyOnce() { + // Applied twice and every route 404s; stripped twice and the external URLs the application + // publishes lose the prefix, so the links it hands out do not resolve. + assertThat(proxy().get("/api" + ProxyFixtureController.ARRIVED_PATH, Map.of()).body()) + .isEqualTo(ProxyFixtureController.ARRIVED_PATH); + assertThat(proxy().get("/dev-api" + ProxyFixtureController.ARRIVED_PATH, Map.of()).body()) + .isEqualTo(ProxyFixtureController.ARRIVED_PATH); + } + + @Test + @DisplayName("the published URI carries the prefix the request came in on") + void publishedUriCarriesTheIncomingPrefix() { + assertThat(proxy().get("/api" + ProxyFixtureController.EXTERNAL_URI_PATH, Map.of()).body()) + .isEqualTo("https://hyeonworks.com/api/v1/result"); + assertThat(proxy().get("/dev-api" + ProxyFixtureController.EXTERNAL_URI_PATH, Map.of()).body()) + .isEqualTo("https://hyeonworks.com/dev-api/v1/result"); + } + + @Test + @DisplayName("a client cannot inject its own prefix") + void clientCannotInjectAPrefix() { + // X-Forwarded-Prefix is set per location, so a client's value is replaced. Left to accumulate, + // it is a path-traversal primitive in every link the application publishes. + NginxProxyHarness.Response response = + proxy() + .get( + "/api" + ProxyFixtureController.EXTERNAL_URI_PATH, + Map.of("X-Forwarded-Prefix", "/evil")); + + assertThat(response.body()).isEqualTo("https://hyeonworks.com/api/v1/result"); + } + + @Test + @DisplayName("a body within the proxy's limit reaches the application") + void bodyWithinTheProxyLimitArrives() { + NginxProxyHarness.Response response = + proxy().post("/api" + ProxyFixtureController.ECHO_PATH, 1024); + + assertThat(response.status()).isEqualTo(200); + assertThat(response.body()).isEqualTo("1024"); + } + + @Test + @DisplayName("a body over the proxy's limit is refused at the proxy") + void oversizedBodyIsRefusedAtTheProxy() { + // The layering the design asks to be written down: this one never reaches the application, so + // the client gets Nginx's HTML rather than a problem document. That is the consequence of + // setting the edge limit below the application's, and it is why the manifest in + // docs/web/repository-adaptation.md §8 says where each bound belongs. + NginxProxyHarness.Response response = + proxy().post("/api" + ProxyFixtureController.ECHO_PATH, 3 * 1024 * 1024); + + assertThat(response.status()).isEqualTo(413); + } + + @Test + @DisplayName("an unknown prefix is not proxied at all") + void unknownPrefixIsNotProxied() { + // Only the declared prefixes are routed. A catch-all location would expose the application's + // whole path space under a name nobody published. + assertThat(proxy().get("/internal" + ProxyFixtureController.ARRIVED_PATH, Map.of()).status()) + .isEqualTo(404); + } +} diff --git a/src/adapter/inbound/web/src/nginxProxyTest/java/dev/caskeleton/adapter/inbound/web/testkit/proxy/NginxProxyHarness.java b/src/adapter/inbound/web/src/nginxProxyTest/java/dev/caskeleton/adapter/inbound/web/testkit/proxy/NginxProxyHarness.java new file mode 100644 index 00000000..1c659d3b --- /dev/null +++ b/src/adapter/inbound/web/src/nginxProxyTest/java/dev/caskeleton/adapter/inbound/web/testkit/proxy/NginxProxyHarness.java @@ -0,0 +1,151 @@ +package dev.caskeleton.adapter.inbound.web.testkit.proxy; + +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.images.builder.Transferable; +import org.testcontainers.utility.DockerImageName; + +/** + * A real Nginx in front of the application under test. + * + *

Real, because every property this lane checks is a property of Nginx's own behaviour. "{@code + * proxy_set_header} replaces rather than appends" and "a trailing slash on {@code proxy_pass} + * strips the location prefix" are facts about the proxy; asserting them against a hand-written stub + * would be asserting what the author already believed. + * + *

The application runs in this JVM. Starting it in a container too would need an image built per + * run, and it would put the thing under test behind a second layer whose failures look identical to + * the proxy's. + */ +public final class NginxProxyHarness implements AutoCloseable { + + private static final DockerImageName IMAGE = DockerImageName.parse("nginx:1.27-alpine"); + + private final GenericContainer nginx; + + /** + * Starts Nginx pointed at a locally running application. + * + * @param applicationPort the port the application is listening on + */ + @SuppressWarnings("resource") + public NginxProxyHarness(int applicationPort) { + // `host.docker.internal` is not resolvable on stock Linux Docker, so the host gateway is added + // explicitly. Without it the lane fails with a connection refused inside the container, which + // reads as an Nginx configuration error and is not one. + this.nginx = + new GenericContainer<>(IMAGE) + .withExposedPorts(8080) + .withExtraHost("host.docker.internal", "host-gateway") + .withCopyToContainer( + Transferable.of(configuration(applicationPort)), "/etc/nginx/nginx.conf") + .withCopyToContainer( + Transferable.of(resource("nginx/proxy_headers.conf")), + "/etc/nginx/proxy_headers.conf"); + nginx.start(); + } + + /** The base URL callers reach the proxy on. */ + public String baseUrl() { + return "http://" + nginx.getHost() + ":" + nginx.getMappedPort(8080); + } + + /** + * Sends one request through the proxy. + * + * @param path the path, including its public prefix + * @param headers headers the client sends, including any it is trying to spoof + */ + public Response get(String path, Map headers) { + return send("GET", path, headers, null); + } + + /** + * Posts a body of the given size through the proxy. + * + * @param path the path, including its public prefix + * @param bodyBytes how large a body to send + */ + public Response post(String path, int bodyBytes) { + return send( + "POST", path, Map.of("Content-Type", "application/octet-stream"), new byte[bodyBytes]); + } + + private Response send(String method, String path, Map headers, byte[] body) { + try { + HttpURLConnection connection = + (HttpURLConnection) URI.create(baseUrl() + path).toURL().openConnection(); + connection.setRequestMethod(method); + connection.setConnectTimeout(10_000); + connection.setReadTimeout(30_000); + headers.forEach(connection::setRequestProperty); + if (body != null) { + connection.setDoOutput(true); + connection.setFixedLengthStreamingMode(body.length); + try (java.io.OutputStream out = connection.getOutputStream()) { + out.write(body); + } catch (IOException refusedMidWrite) { + // Nginx can refuse an oversized body before the client finishes sending it. That is the + // intended behaviour, and the response still carries the status. + } + } + try { + int status = connection.getResponseCode(); + String text; + try (InputStream stream = + status >= 400 ? connection.getErrorStream() : connection.getInputStream()) { + text = stream == null ? "" : new String(stream.readAllBytes(), StandardCharsets.UTF_8); + } + return new Response(status, text.trim()); + } finally { + connection.disconnect(); + } + } catch (IOException e) { + throw new IllegalStateException(method + " " + path + " through the proxy failed", e); + } + } + + @Override + public void close() { + nginx.stop(); + } + + /** Whether a container runtime is available. */ + public static boolean dockerAvailable() { + try { + return org.testcontainers.DockerClientFactory.instance().isDockerAvailable(); + } catch (RuntimeException unavailable) { + return false; + } + } + + private static String configuration(int applicationPort) { + return resource("nginx/nginx.conf") + .replace("APPLICATION_HOST", "host.docker.internal") + .replace("APPLICATION_PORT", Integer.toString(applicationPort)); + } + + private static String resource(String name) { + try (InputStream stream = NginxProxyHarness.class.getClassLoader().getResourceAsStream(name)) { + if (stream == null) { + throw new IllegalStateException(name + " is missing from the lane's resources"); + } + return new String(stream.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException e) { + throw new IllegalStateException(name + " could not be read", e); + } + } + + /** + * One response, as the client saw it. + * + * @param status the HTTP status + * @param body the response body + */ + public record Response(int status, String body) {} +} diff --git a/src/adapter/inbound/web/src/nginxProxyTest/java/dev/caskeleton/adapter/inbound/web/testkit/proxy/ProxyFixtureApplication.java b/src/adapter/inbound/web/src/nginxProxyTest/java/dev/caskeleton/adapter/inbound/web/testkit/proxy/ProxyFixtureApplication.java new file mode 100644 index 00000000..cc0a876b --- /dev/null +++ b/src/adapter/inbound/web/src/nginxProxyTest/java/dev/caskeleton/adapter/inbound/web/testkit/proxy/ProxyFixtureApplication.java @@ -0,0 +1,37 @@ +package dev.caskeleton.adapter.inbound.web.testkit.proxy; + +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.web.SecurityFilterChain; + +/** + * The application Nginx proxies to. + * + *

It binds on all interfaces rather than loopback, because the proxy reaches it from inside a + * container network. A loopback-only bind is the first thing that makes this lane fail with a + * connection refused that looks like a configuration error in Nginx. + */ +// @Configuration, not @TestConfiguration: this source set is never on the application's runtime +// classpath, so nothing here can be component-scanned into a deployment — and @TestConfiguration +// types are not accepted by @SpringBootTest(classes = ...). +@Configuration(proxyBeanMethods = false) +@EnableAutoConfiguration +@Import(ProxyFixtureController.class) +public class ProxyFixtureApplication { + + /** + * Every fixture route is open. + * + *

The leaf carries the security starter. This is a fixture in the test tree and reaches no + * deployment. + */ + @Bean + SecurityFilterChain proxyFixtureSecurity(HttpSecurity http) throws Exception { + return http.csrf(csrf -> csrf.disable()) + .authorizeHttpRequests(requests -> requests.anyRequest().permitAll()) + .build(); + } +} diff --git a/src/adapter/inbound/web/src/nginxProxyTest/resources/nginx/nginx.conf b/src/adapter/inbound/web/src/nginxProxyTest/resources/nginx/nginx.conf new file mode 100644 index 00000000..e03af04d --- /dev/null +++ b/src/adapter/inbound/web/src/nginxProxyTest/resources/nginx/nginx.conf @@ -0,0 +1,45 @@ +# The reverse proxy the platform is actually deployed behind. +# +# Two properties matter here and neither can be tested without a real nginx: +# +# 1. Forwarded headers a client sends are DISCARDED, not merged. `proxy_set_header` replaces the +# header outright, so `X-Forwarded-Host: evil.example` from a client never reaches the +# application. A proxy that appended instead would leave the application to decide which of +# two values to trust, and the conventional choice is the first one — the attacker's. +# +# 2. Each public prefix maps to the application path exactly once. `/api/` and `/dev-api/` both +# proxy to the same upstream root, and the trailing slash on `proxy_pass` is what strips the +# prefix. Without it the application sees `/api/api/v1/...` and every route 404s; stripped +# twice, the prefix vanishes from the external URLs the application publishes. +events {} + +http { + # nginx resolves `upstream` names once at startup. The application under test binds a random + # port, so the address is templated in before the file is copied into the container. + upstream application { + server APPLICATION_HOST:APPLICATION_PORT; + } + + server { + listen 8080; + server_name hyeonworks.com; + + # Refused here rather than in the application. A request line long enough to matter should + # never occupy an application thread, and nginx answers it before one is allocated. + large_client_header_buffers 4 8k; + client_max_body_size 2m; + proxy_read_timeout 30s; + + location /api/ { + include /etc/nginx/proxy_headers.conf; + proxy_set_header X-Forwarded-Prefix /api; + proxy_pass http://application/; + } + + location /dev-api/ { + include /etc/nginx/proxy_headers.conf; + proxy_set_header X-Forwarded-Prefix /dev-api; + proxy_pass http://application/; + } + } +} diff --git a/src/adapter/inbound/web/src/nginxProxyTest/resources/nginx/proxy_headers.conf b/src/adapter/inbound/web/src/nginxProxyTest/resources/nginx/proxy_headers.conf new file mode 100644 index 00000000..8ade88cf --- /dev/null +++ b/src/adapter/inbound/web/src/nginxProxyTest/resources/nginx/proxy_headers.conf @@ -0,0 +1,22 @@ +# The authoritative forwarded headers, included by every location. +# +# Included rather than declared once at the server level, because nginx's inheritance rule for +# array directives is replacement, not merging: a single `proxy_set_header` inside a `location` +# discards every `proxy_set_header` inherited from `server`. A configuration that sets the security +# headers at the server level and then adds one per-location header silently sends none of the +# security headers — and the only symptom is the application quietly trusting the client again. +# +# Every line SETS, never adds. An inbound X-Forwarded-For from a client is replaced by +# $remote_addr, and an inbound X-Forwarded-Host by this deployment's public name. +proxy_set_header Host $host; +proxy_set_header X-Real-IP $remote_addr; +proxy_set_header X-Forwarded-For $remote_addr; +proxy_set_header X-Forwarded-Host hyeonworks.com; +# This deployment terminates TLS at the edge, so the scheme the application sees is a fact about +# the edge and not about the connection nginx opened to the upstream. +proxy_set_header X-Forwarded-Proto https; +proxy_set_header X-Forwarded-Port 443; +# `Forwarded` is the standardised header and clients send it too. Clearing it is required: a +# framework that reads RFC 7239 in preference to the X- headers would otherwise take the client's +# word over the proxy's, leaving every rule above intact and irrelevant. +proxy_set_header Forwarded ""; diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/admin/platform/WebPlatformStartupValidatorTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/admin/platform/WebPlatformStartupValidatorTest.java new file mode 100644 index 00000000..409278c8 --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/admin/platform/WebPlatformStartupValidatorTest.java @@ -0,0 +1,139 @@ +package dev.caskeleton.adapter.inbound.web.admin.platform; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * The check that would have caught this session's worst defect at startup. + * + *

The problem catalog existed, was fully tested, and was reached by nothing on the framework's + * error path — found by accident, weeks of work later, through a cross-stack parity recording. + * Every case here is the same shape: a control that is configured and not wired behaves exactly + * like one that works, right up until it is needed. + */ +class WebPlatformStartupValidatorTest { + + private static final List REQUIRED = + List.of("problemHandler", "budgetFilter", "throttleFilter"); + + @Test + @DisplayName("a fully wired platform starts") + void fullyWiredPlatformStarts() { + assertThatCode( + () -> + new WebPlatformStartupValidator(REQUIRED) + .validate( + snapshot( + Map.of( + "problemHandler", + true, + "budgetFilter", + true, + "throttleFilter", + true)))) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("a declared control that is not wired fails startup") + void uninstalledControlFailsStartup() { + assertThatThrownBy( + () -> + new WebPlatformStartupValidator(REQUIRED) + .validate( + snapshot( + Map.of( + "problemHandler", + true, + "budgetFilter", + false, + "throttleFilter", + true)))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("budgetFilter"); + } + + @Test + @DisplayName("a control absent from the snapshot entirely fails startup") + void absentControlFailsStartup() { + // Absent and false must behave the same. A control missing from the map is a control nobody + // even attempted to wire, which is worse than one that failed to. + assertThatThrownBy( + () -> + new WebPlatformStartupValidator(REQUIRED) + .validate(snapshot(Map.of("problemHandler", true)))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("budgetFilter") + .hasMessageContaining("throttleFilter"); + } + + @Test + @DisplayName("every missing control is named at once") + void everyMissingControlIsNamedAtOnce() { + // Reporting the first would send an operator through one restart per problem, and each restart + // is a deploy. + assertThatThrownBy( + () -> + new WebPlatformStartupValidator(REQUIRED) + .validate( + snapshot( + Map.of( + "problemHandler", + false, + "budgetFilter", + false, + "throttleFilter", + false)))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("problemHandler") + .hasMessageContaining("budgetFilter") + .hasMessageContaining("throttleFilter"); + } + + @Test + @DisplayName("an empty problem catalog fails startup") + void emptyProblemCatalogFailsStartup() { + // The specific defect this session found: without a catalog the framework answers with its + // own document, which is RFC 9457-shaped and carries no code. + assertThatThrownBy( + () -> + new WebPlatformStartupValidator(List.of()) + .validate( + new WebPlatformSnapshot( + "servlet", + List.of(1), + List.of(), + Map.of(), + Map.of(), + List.of(), + Map.of()))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("no problem catalog"); + } + + @Test + @DisplayName("the snapshot names which controls are not wired") + void snapshotNamesUninstalledControls() { + assertThat( + snapshot(Map.of("problemHandler", true, "budgetFilter", false, "throttleFilter", false)) + .uninstalledControls()) + .containsExactly("budgetFilter", "throttleFilter"); + } + + private static WebPlatformSnapshot snapshot(Map controls) { + return new WebPlatformSnapshot( + "servlet", + List.of(1), + List.of("VALIDATION_FAILED", "INTERNAL_ERROR"), + Map.of("standard", "8MiB body"), + Map.of("global-write", "32 concurrent"), + List.of("sensitive", "revalidated"), + controls); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/admin/route/WebRouteInventoryTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/admin/route/WebRouteInventoryTest.java new file mode 100644 index 00000000..d5bad59c --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/admin/route/WebRouteInventoryTest.java @@ -0,0 +1,135 @@ +package dev.caskeleton.adapter.inbound.web.admin.route; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.inbound.web.core.ApiMajorVersion; +import dev.caskeleton.adapter.inbound.web.core.WebOperationName; +import dev.caskeleton.adapter.inbound.web.core.WebRouteId; +import dev.caskeleton.adapter.inbound.web.operation.HttpMethodSemantic; +import dev.caskeleton.adapter.inbound.web.operation.InMemoryWebOperationCatalog; +import dev.caskeleton.adapter.inbound.web.operation.WebOperationProfile; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** An endpoint that appears without a review is an endpoint whose policies nobody chose. */ +class WebRouteInventoryTest { + + @Test + @DisplayName("a duplicate method, path and version is refused") + void aDuplicateMethodPathAndVersionIsRefused() { + WebRouteInventory inventory = new WebRouteInventory(); + inventory.add(route("documents.get", "GET", "/api/v1/documents/{id}", 1)); + + assertThatThrownBy( + () -> inventory.add(route("documents.get", "GET", "/api/v1/documents/{id}", 1))) + .as("which handler wins would depend on classpath scanning order") + .isInstanceOf(RouteInventoryMismatchException.class) + .hasMessageContaining("duplicate route"); + } + + @Test + @DisplayName("the same path in a different version is a different route") + void theSamePathInADifferentVersionIsADifferentRoute() { + WebRouteInventory inventory = new WebRouteInventory(); + inventory.add(route("documents.get", "GET", "/api/v1/documents/{id}", 1)); + + assertThatCode(() -> inventory.add(route("documents.get", "GET", "/api/v2/documents/{id}", 2))) + .doesNotThrowAnyException(); + assertThat(inventory.size()).isEqualTo(2); + } + + @Test + @DisplayName("a route serving an unregistered operation is refused") + void aRouteServingAnUnregisteredOperationIsRefused() { + WebRouteInventory inventory = new WebRouteInventory(); + inventory.add(route("documents.get", "GET", "/api/v1/documents/{id}", 1)); + inventory.add(route("documents.create", "POST", "/api/v1/documents", 1)); + + InMemoryWebOperationCatalog catalog = new InMemoryWebOperationCatalog(); + catalog.register(WebOperationProfile.readOnly("documents.get")); + + assertThatThrownBy(() -> inventory.requireRegisteredOperations(catalog)) + .as("an unregistered operation has no budget, authorization or idempotency policy") + .isInstanceOf(RouteInventoryMismatchException.class) + .hasMessageContaining("documents.create"); + } + + @Test + @DisplayName("every operation registered means the check passes") + void everyOperationRegisteredMeansTheCheckPasses() { + WebRouteInventory inventory = new WebRouteInventory(); + inventory.add(route("documents.get", "GET", "/api/v1/documents/{id}", 1)); + + InMemoryWebOperationCatalog catalog = new InMemoryWebOperationCatalog(); + catalog.register(WebOperationProfile.readOnly("documents.get")); + + assertThatCode(() -> inventory.requireRegisteredOperations(catalog)).doesNotThrowAnyException(); + } + + @Test + @DisplayName("a manifest disagreement names both directions") + void aManifestDisagreementNamesBothDirections() { + WebRouteInventory inventory = new WebRouteInventory(); + inventory.add(route("documents.get", "GET", "/api/v1/documents/{id}", 1)); + + assertThatCode(() -> inventory.requireMatches(inventory.keys())).doesNotThrowAnyException(); + assertThatThrownBy(() -> inventory.requireMatches(Set.of("GET v1 /api/v1/other"))) + .as("an added route and a removed one are different problems and both must be reported") + .isInstanceOf(RouteInventoryMismatchException.class) + .hasMessageContaining("added=") + .hasMessageContaining("removed="); + } + + @Test + @DisplayName("a sunset date implies deprecation") + void aSunsetDateImpliesDeprecation() { + assertThatThrownBy( + () -> + new WebRouteContract( + new WebRouteId("GET /api/v1/x"), + new WebOperationName("documents.get"), + new ApiMajorVersion(1), + HttpMethodSemantic.GET, + "/api/v1/x", + List.of(), + List.of(), + false, + Optional.of(Instant.parse("2027-01-01T00:00:00Z")))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("the inventory order is deterministic") + void theInventoryOrderIsDeterministic() { + WebRouteInventory first = new WebRouteInventory(); + WebRouteInventory second = new WebRouteInventory(); + first.add(route("b", "POST", "/api/v1/b", 1)); + first.add(route("a", "GET", "/api/v1/a", 1)); + second.add(route("a", "GET", "/api/v1/a", 1)); + second.add(route("b", "POST", "/api/v1/b", 1)); + + assertThat(List.copyOf(first.routes().keySet())) + .as("a gate that compares against a manifest cannot tolerate scan-order variance") + .containsExactlyInAnyOrderElementsOf(second.routes().keySet()); + assertThat(first.keys()).isEqualTo(second.keys()); + } + + private static WebRouteContract route(String operation, String method, String path, int version) { + return new WebRouteContract( + new WebRouteId(method + " " + path), + new WebOperationName(operation.length() >= 3 ? operation : "op." + operation), + new ApiMajorVersion(version), + HttpMethodSemantic.valueOf(method), + path, + List.of("application/json"), + List.of("application/json"), + false, + Optional.empty()); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/advanced/blockingbridge/BlockingBridgeBudgetTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/advanced/blockingbridge/BlockingBridgeBudgetTest.java new file mode 100644 index 00000000..44aff52f --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/advanced/blockingbridge/BlockingBridgeBudgetTest.java @@ -0,0 +1,143 @@ +package dev.caskeleton.adapter.inbound.web.advanced.blockingbridge; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; +import java.util.List; +import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * Offloading blocking work off the event loop, without the offload becoming the problem. + * + *

Reactor's {@code boundedElastic()} is available from anywhere and unbounded in practice, so a + * controller that calls it has silently opted the whole application into an unbounded pool. Every + * such call site is invisible until the pool is the thing consuming the heap. + */ +@Tag("web-advanced") +class BlockingBridgeBudgetTest { + + private static BlockingBridgeProfile profile(int concurrency) { + return new BlockingBridgeProfile( + Set.of("jpa.read", "jpa.write"), concurrency, Duration.ofMillis(100)); + } + + @Test + @DisplayName("an unregistered operation cannot use the bridge") + void unregisteredOperationIsRefused() { + // The registration is what makes the offloads enumerable. + BlockingBridgeBudget budget = new BlockingBridgeBudget(profile(4)); + + assertThatThrownBy(() -> budget.acquire("legacy.soap.call")) + .isInstanceOf(BlockingBridgeRejectedException.class) + .extracting(failure -> ((BlockingBridgeRejectedException) failure).reason()) + .isEqualTo(BlockingBridgeRejectedException.Reason.NOT_REGISTERED); + } + + @Test + @DisplayName("concurrency never exceeds the configured bound") + void concurrencyIsBounded() throws Exception { + // Invisible from throughput and from latency: a bridge whose bound is not applied looks + // exactly like one whose bound is generous, until the pool is the heap. + int bound = 4; + BlockingBridgeBudget budget = + new BlockingBridgeBudget( + new BlockingBridgeProfile(Set.of("jpa.read"), bound, Duration.ofSeconds(5))); + + try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) { + List> futures = new java.util.ArrayList<>(); + for (int i = 0; i < 64; i++) { + futures.add( + executor.submit( + () -> { + budget.acquire("jpa.read"); + try { + Thread.sleep(5); + } finally { + budget.release(); + } + return null; + })); + } + for (Future future : futures) { + future.get(30, TimeUnit.SECONDS); + } + } + + assertThat(budget.peakConcurrency()).isLessThanOrEqualTo(bound); + assertThat(budget.inFlight()).isZero(); + } + + @Test + @DisplayName("a caller that cannot get a slot in time is refused, not queued") + void queueTimeoutRefuses() throws Exception { + // A slow dependency's callers otherwise accumulate until the heap does, and the fast + // dependencies starve behind them. + BlockingBridgeBudget budget = + new BlockingBridgeBudget( + new BlockingBridgeProfile(Set.of("jpa.read"), 1, Duration.ofMillis(20))); + CountDownLatch hold = new CountDownLatch(1); + CountDownLatch holding = new CountDownLatch(1); + + try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) { + Future holder = + executor.submit( + () -> { + budget.acquire("jpa.read"); + holding.countDown(); + hold.await(); + budget.release(); + return null; + }); + assertThat(holding.await(10, TimeUnit.SECONDS)).isTrue(); + + assertThatThrownBy(() -> budget.acquire("jpa.read")) + .isInstanceOf(BlockingBridgeRejectedException.class) + .extracting(failure -> ((BlockingBridgeRejectedException) failure).reason()) + .isEqualTo(BlockingBridgeRejectedException.Reason.QUEUE_TIMEOUT); + assertThat(budget.rejectionsFor("jpa.read")).isOne(); + hold.countDown(); + holder.get(10, TimeUnit.SECONDS); + } + } + + @Test + @DisplayName("refusals of unregistered operations do not become map keys") + void unregisteredRefusalsDoNotGrowTheMap() throws Exception { + // The caller supplies the string, and a map keyed on it grows with whatever is passed — the + // same unbounded-cardinality problem a metric tagged with client input has. + BlockingBridgeBudget budget = new BlockingBridgeBudget(profile(4)); + for (int i = 0; i < 100; i++) { + String name = "unknown." + i; + assertThatThrownBy(() -> budget.acquire(name)) + .isInstanceOf(BlockingBridgeRejectedException.class); + } + + assertThat(budget.unregisteredRejections()).isEqualTo(100); + assertThat(budget.rejectionsFor("unknown.7")).isZero(); + } + + @Test + @DisplayName("a bridge with no registered operation is refused at construction") + void emptyRegistrationIsRefused() { + assertThatThrownBy(() -> new BlockingBridgeProfile(Set.of(), 4, Duration.ofSeconds(1))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("do not enable the bridge"); + } + + @Test + @DisplayName("an unbounded queue wait is refused") + void unboundedQueueWaitIsRefused() { + assertThatThrownBy(() -> new BlockingBridgeProfile(Set.of("a"), 4, Duration.ZERO)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("the fast dependencies starve behind them"); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/advanced/codec/RepresentationBackendScopeTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/advanced/codec/RepresentationBackendScopeTest.java new file mode 100644 index 00000000..c01bacbd --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/advanced/codec/RepresentationBackendScopeTest.java @@ -0,0 +1,107 @@ +package dev.caskeleton.adapter.inbound.web.advanced.codec; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.inbound.web.moduleboundary.WebBuildModel; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import java.util.regex.Pattern; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * The two Advanced representations stay off every adopter's runtime classpath. + * + *

Written after the failure, not before it. Both jars were {@code implementation} on the + * reasoning that a missing backend would surface as a {@code NoClassDefFoundError} at the first + * request that negotiated one. What that missed is that the jars change behaviour by being present: + * Spring Boot's Jackson auto-configuration registers an {@code xmlMapper} and a {@code cborMapper} + * as soon as each backend is on the runtime classpath. The composition root then held three {@code + * ObjectMapper} beans, every {@code @Autowired ObjectMapper} became ambiguous, and the application + * would not start. + * + *

The second consequence is the one worth a test rather than a comment. Spring also registers an + * XML message converter, so every deployment silently began accepting {@code application/xml} + * request bodies — an XXE surface acquired by adding a dependency, on a capability that is supposed + * to be off unless a deployment names it. + */ +@Tag("web-advanced") +class RepresentationBackendScopeTest { + + private static final Map COORDINATES = + Map.of( + WebRepresentation.CBOR, "tools.jackson.dataformat:jackson-dataformat-cbor", + WebRepresentation.XML, "tools.jackson.dataformat:jackson-dataformat-xml"); + + private static String buildFile() { + Path leaf = WebBuildModel.mainSourceRoot().getParent().getParent().getParent(); + Path file = leaf.resolve("build.gradle"); + if (!Files.isRegularFile(file)) { + throw new IllegalStateException( + "cannot locate this leaf's build.gradle at " + file + "; the scope rule checks nothing"); + } + try { + return Files.readString(file); + } catch (IOException failure) { + throw new UncheckedIOException(failure); + } + } + + private static boolean declares(String scope, String coordinate) { + return Pattern.compile( + "^\\s*" + scope + "\\s+'" + Pattern.quote(coordinate) + "(:|')", Pattern.MULTILINE) + .matcher(buildFile()) + .find(); + } + + @Test + @DisplayName("neither backend is an implementation dependency") + void backendsAreNotOnTheRuntimeClasspath() { + COORDINATES.forEach( + (representation, coordinate) -> + assertThat(declares("implementation", coordinate)) + .as( + "%s must not be an implementation dependency: on an adopter's runtime classpath" + + " Spring Boot registers a mapper bean for it, and for XML a message" + + " converter that starts parsing request bodies", + coordinate) + .isFalse()); + } + + @Test + @DisplayName("both backends are compile-only, so the factories still compile") + void backendsAreCompileOnly() { + COORDINATES.forEach( + (representation, coordinate) -> + assertThat(declares("compileOnly", coordinate)) + .as("%s must be compileOnly", coordinate) + .isTrue()); + } + + @Test + @DisplayName("both backends are on the test classpath, so the codecs are actually exercised") + void backendsAreOnTheTestClasspath() { + // compileOnly alone would leave the codec tests unable to run, and a codec whose tests cannot + // run is worse than one that is absent. + COORDINATES.forEach( + (representation, coordinate) -> { + assertThat(declares("testImplementation", coordinate)) + .as("%s must be on the test classpath", coordinate) + .isTrue(); + assertThat(representation.available()) + .as("%s must actually resolve in this lane", coordinate) + .isTrue(); + }); + } + + @Test + @DisplayName("JSON needs no backend, because it is the Stable representation") + void jsonNeedsNoBackend() { + assertThat(WebRepresentation.JSON.available()).isTrue(); + assertThat(WebRepresentation.JSON.requiresOptIn()).isFalse(); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/advanced/codec/WebCodecMapperTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/advanced/codec/WebCodecMapperTest.java new file mode 100644 index 00000000..900b0e7e --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/advanced/codec/WebCodecMapperTest.java @@ -0,0 +1,175 @@ +package dev.caskeleton.adapter.inbound.web.advanced.codec; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.UUID; +import javax.xml.stream.XMLInputFactory; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import tools.jackson.databind.ObjectMapper; + +/** + * The two Advanced representations, and the property that makes them safe to offer. + * + *

Each must be exactly as strict as the JSON profile. A representation that accepts what the + * primary one rejects is a validation bypass reachable by changing one header, and the natural way + * to add a codec — build a mapper with its defaults — produces exactly that. + */ +@Tag("web-advanced") +class WebCodecMapperTest { + + /** A value with the two wire types most likely to be coerced. */ + record Value(UUID id, Instant createdAt, int count) {} + + /** A target with one field, for the unknown-property case. */ + record Narrow(String name) {} + + private static final Value SAMPLE = + new Value( + UUID.fromString("00000000-0000-0000-0000-000000000001"), + Instant.parse("2026-08-25T00:00:00Z"), + 7); + + private static ObjectMapper cbor() { + return WebCborMapperFactory.create(CodecBudget.conventional(WebRepresentation.CBOR)); + } + + private static ObjectMapper xml() { + return WebXmlMapperFactory.create(CodecBudget.conventional(WebRepresentation.XML)); + } + + @Test + @DisplayName("a CBOR round trip preserves the wire types") + void cborRoundTripPreservesWireTypes() { + ObjectMapper mapper = cbor(); + + Value restored = mapper.readValue(mapper.writeValueAsBytes(SAMPLE), Value.class); + + assertThat(restored).isEqualTo(SAMPLE); + } + + @Test + @DisplayName("CBOR refuses an unknown property, as JSON does") + void cborRefusesUnknownProperties() { + // Ignoring it would turn a client's misspelled field into a silent default, and only on this + // representation. + ObjectMapper mapper = cbor(); + byte[] encoded = mapper.writeValueAsBytes(java.util.Map.of("name", "a", "surprise", "b")); + + assertThatThrownBy(() -> mapper.readValue(encoded, Narrow.class)).isInstanceOf(Exception.class); + } + + @Test + @DisplayName("CBOR refuses to coerce a string into a number") + void cborRefusesScalarCoercion() { + // "5" becoming 5 on one representation and not the other is the validation bypass this + // configuration exists to close. + ObjectMapper mapper = cbor(); + byte[] encoded = + mapper.writeValueAsBytes( + java.util.Map.of( + "id", SAMPLE.id().toString(), + "createdAt", SAMPLE.createdAt().toString(), + "count", "7")); + + assertThatThrownBy(() -> mapper.readValue(encoded, Value.class)).isInstanceOf(Exception.class); + } + + @Test + @DisplayName("a CBOR mapper built from another representation's budget is refused") + void cborRefusesTheWrongBudget() { + assertThatThrownBy( + () -> WebCborMapperFactory.create(CodecBudget.conventional(WebRepresentation.XML))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("wrong limits"); + } + + @Test + @DisplayName("an XML round trip preserves the wire types") + void xmlRoundTripPreservesWireTypes() { + ObjectMapper mapper = xml(); + + Value restored = mapper.readValue(mapper.writeValueAsString(SAMPLE), Value.class); + + assertThat(restored).isEqualTo(SAMPLE); + } + + @Test + @DisplayName("the XML mapper cannot be built on an unhardened factory") + void xmlRefusesAnUnhardenedFactory() { + // Refused rather than silently re-hardened. A caller that passed one has another code path + // that builds factories, and quietly fixing this instance leaves that path shipping the + // vulnerability. + assertThatThrownBy( + () -> + WebXmlMapperFactory.create( + CodecBudget.conventional(WebRepresentation.XML), XMLInputFactory.newFactory())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("the path that produced this one"); + } + + @Test + @DisplayName("an external entity is not resolved by the XML mapper") + void xmlMapperDoesNotResolveExternalEntities() { + // The end-to-end version of the SecureXmlInputFactory test: the hardening has to survive being + // handed to Jackson, which builds its own reader over the factory. + String xxe = + "]>" + + "&secret;2026-08-25T00:00:00Z" + + "1"; + + assertThatThrownBy(() -> xml().readValue(xxe, Value.class)).isInstanceOf(Exception.class); + } + + @Test + @DisplayName("entity expansion is not performed by the XML mapper") + void xmlMapperDoesNotExpandEntities() { + String laughs = + "" + + "" + + "]>" + + "&lol2;2026-08-25T00:00:00Z" + + "1"; + + assertThatThrownBy(() -> xml().readValue(laughs, Value.class)).isInstanceOf(Exception.class); + } + + @Test + @DisplayName("XML refuses an unknown element, as JSON refuses an unknown property") + void xmlRefusesUnknownElements() { + String extra = "ab"; + + assertThatThrownBy(() -> xml().readValue(extra, Narrow.class)).isInstanceOf(Exception.class); + } + + @Test + @DisplayName("plain XML still parses") + void plainXmlStillParses() { + // Otherwise the hardening would be indistinguishable from a broken codec. + assertThat(xml().readValue("ok", Narrow.class).name()) + .isEqualTo("ok"); + } + + @Test + @DisplayName("a document deeper than the budget is refused") + void overNestedDocumentIsRefused() { + // The bound the body size cannot provide: a binary format declares a length before its + // contents, so a few bytes can ask for far more work than they cost to send. + CodecBudget shallow = new CodecBudget(WebRepresentation.CBOR, 1_048_576, 4, 10); + ObjectMapper mapper = WebCborMapperFactory.create(shallow); + Object nested = + java.util.Map.of( + "a", + java.util.Map.of( + "b", java.util.Map.of("c", java.util.Map.of("d", java.util.Map.of("e", "deep"))))); + byte[] encoded = cbor().writeValueAsBytes(nested); + + assertThatThrownBy(() -> mapper.readValue(encoded, Object.class)).isInstanceOf(Exception.class); + assertThat(new String("x".getBytes(StandardCharsets.UTF_8), StandardCharsets.UTF_8)) + .isEqualTo("x"); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/advanced/codec/WebCodecSecurityTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/advanced/codec/WebCodecSecurityTest.java new file mode 100644 index 00000000..11a4a8f6 --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/advanced/codec/WebCodecSecurityTest.java @@ -0,0 +1,168 @@ +package dev.caskeleton.adapter.inbound.web.advanced.codec; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.StringReader; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import javax.xml.stream.XMLInputFactory; +import javax.xml.stream.XMLStreamReader; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * The two XML defaults that are on, are dangerous, and produce no error when they fire. + * + *

Both are old enough that "everyone knows", which is exactly why they keep shipping. + */ +@Tag("web-advanced") +class WebCodecSecurityTest { + + private static final String XXE = + "]>" + + "&secret;"; + + private static final String BILLION_LAUGHS = + "" + + "" + + "" + + "]>" + + "&lol3;"; + + @Test + @DisplayName("an external entity is refused rather than resolved") + void externalEntityIsRefused() { + // A resolved entity is a file read performed by the parser on the sender's behalf, and its + // content ends up in a field of the resulting DTO. + XMLInputFactory factory = SecureXmlInputFactory.create(); + + assertThatThrownBy(() -> drain(factory.createXMLStreamReader(new StringReader(XXE)))) + .isInstanceOf(Exception.class); + } + + @Test + @DisplayName("entity expansion is refused rather than performed") + void entityExpansionIsRefused() { + // Ten nested internal entities expand a two-hundred-byte document into gigabytes of heap, + // before any application code sees it and without a single outbound request. + XMLInputFactory factory = SecureXmlInputFactory.create(); + + assertThatThrownBy(() -> drain(factory.createXMLStreamReader(new StringReader(BILLION_LAUGHS)))) + .isInstanceOf(Exception.class); + } + + @Test + @DisplayName("a factory built anywhere else can be checked against the same standard") + void factoryConfigurationIsInspectable() { + // The failure this guards is a configuration path that builds its own factory and never + // reaches create(). + assertThat(SecureXmlInputFactory.secure(SecureXmlInputFactory.create())).isTrue(); + assertThat(SecureXmlInputFactory.secure(XMLInputFactory.newFactory())).isFalse(); + } + + @Test + @DisplayName("plain XML parses normally") + void plainXmlStillParses() throws Exception { + // Otherwise the hardening would be indistinguishable from a broken parser. + XMLInputFactory factory = SecureXmlInputFactory.create(); + + assertThat(drain(factory.createXMLStreamReader(new StringReader("ok")))) + .contains("ok"); + } + + @Test + @DisplayName("each representation has its own decode budget") + void budgetsArePerRepresentation() { + // A megabyte of CBOR can declare an array of a billion elements in a handful of bytes. + CodecBudget cbor = CodecBudget.conventional(WebRepresentation.CBOR); + + assertThat(cbor.bodyWithinBounds(1_048_576)).isTrue(); + assertThat(cbor.bodyWithinBounds(1_048_577)).isFalse(); + assertThat(cbor.mayDescend(31)).isTrue(); + assertThat(cbor.mayDescend(32)).isFalse(); + assertThat(cbor.mayGrow(9_999)).isTrue(); + assertThat(cbor.mayGrow(10_000)).isFalse(); + } + + @Test + @DisplayName("a codec budget without a depth or collection limit is refused") + void missingLimitsAreRefused() { + assertThatThrownBy(() -> new CodecBudget(WebRepresentation.XML, 1024, 0, 10)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("no exception handler can turn into a 400"); + assertThatThrownBy(() -> new CodecBudget(WebRepresentation.CBOR, 1024, 10, 0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("the body size never bounded"); + } + + @Test + @DisplayName("all three gates must agree before a non-JSON representation is negotiated") + void negotiationRequiresAllThreeGates() { + // An Accept header from an arbitrary client is not evidence that the route was ever tested + // against the XML codec. + RepresentationNegotiationPolicy policy = + new RepresentationNegotiationPolicy( + Set.of(WebRepresentation.JSON, WebRepresentation.CBOR), Set.of("mobile-app")); + Set produces = Set.of(WebRepresentation.JSON, WebRepresentation.CBOR); + + assertThat(policy.negotiate(List.of("application/cbor"), produces, Optional.of("mobile-app"))) + .contains(WebRepresentation.CBOR); + assertThat(policy.negotiate(List.of("application/cbor"), produces, Optional.of("someone-else"))) + .isEmpty(); + assertThat( + policy.negotiate( + List.of("application/cbor"), + Set.of(WebRepresentation.JSON), + Optional.of("mobile-app"))) + .isEmpty(); + assertThat(policy.negotiate(List.of("application/xml"), produces, Optional.of("mobile-app"))) + .isEmpty(); + } + + @Test + @DisplayName("JSON needs no allowlist and no client identity") + void jsonNeedsNoOptIn() { + assertThat( + RepresentationNegotiationPolicy.jsonOnly() + .negotiate( + List.of("application/json"), Set.of(WebRepresentation.JSON), Optional.empty())) + .contains(WebRepresentation.JSON); + } + + @Test + @DisplayName("an unsatisfiable Accept is a 406 rather than a silent fallback to JSON") + void unsatisfiableAcceptDoesNotFallBack() { + // A client that asked only for CBOR and gets JSON receives bytes it will try to parse as CBOR, + // which fails somewhere far from here. + assertThat( + RepresentationNegotiationPolicy.jsonOnly() + .negotiate( + List.of("application/cbor"), Set.of(WebRepresentation.JSON), Optional.empty())) + .isEmpty(); + } + + @Test + @DisplayName("media type parameters do not defeat the match") + void mediaTypeParametersAreIgnored() { + assertThat(WebRepresentation.fromMediaType("application/json; charset=utf-8")) + .contains(WebRepresentation.JSON); + assertThat(WebRepresentation.fromMediaType("application/yaml")).isEmpty(); + } + + private static String drain(XMLStreamReader reader) { + StringBuilder text = new StringBuilder(); + try { + while (reader.hasNext()) { + if (reader.next() == XMLStreamReader.CHARACTERS) { + text.append(reader.getText()); + } + } + return text.toString(); + } catch (javax.xml.stream.XMLStreamException failure) { + throw new IllegalStateException(failure); + } + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/advanced/functional/FunctionalRouteRegistryTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/advanced/functional/FunctionalRouteRegistryTest.java new file mode 100644 index 00000000..4e3860d9 --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/advanced/functional/FunctionalRouteRegistryTest.java @@ -0,0 +1,178 @@ +package dev.caskeleton.adapter.inbound.web.advanced.functional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.inbound.web.budget.WebBudgetProfileName; +import dev.caskeleton.adapter.inbound.web.core.WebOperationName; +import dev.caskeleton.adapter.inbound.web.operation.AdmissionProfileName; +import dev.caskeleton.adapter.inbound.web.operation.AuthorizationProfileName; +import dev.caskeleton.adapter.inbound.web.operation.CachePolicyName; +import dev.caskeleton.adapter.inbound.web.operation.HttpMethodSemantic; +import dev.caskeleton.adapter.inbound.web.operation.IdempotencyPolicy; +import dev.caskeleton.adapter.inbound.web.operation.InMemoryWebOperationCatalog; +import dev.caskeleton.adapter.inbound.web.operation.MutationKind; +import dev.caskeleton.adapter.inbound.web.operation.PreconditionPolicy; +import dev.caskeleton.adapter.inbound.web.operation.ResponseProfileName; +import dev.caskeleton.adapter.inbound.web.operation.WebOperationCatalog; +import dev.caskeleton.adapter.inbound.web.operation.WebOperationProfile; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * A functional route is a lambda registered against a path. + * + *

There is no annotation for a scanner to find and no class name to infer from, so a route + * without a declared operation has no budget, no authorization profile and no idempotency policy — + * and nothing anywhere will say so. It will simply serve. + */ +@Tag("web-advanced") +class FunctionalRouteRegistryTest { + + private static final WebOperationName READ = new WebOperationName("items.read"); + + private static WebOperationCatalog catalog(HttpMethodSemantic method, MutationKind kind) { + InMemoryWebOperationCatalog catalog = new InMemoryWebOperationCatalog(); + catalog.register( + new WebOperationProfile( + READ, + method, + kind, + WebBudgetProfileName.standard(), + AuthorizationProfileName.standard(), + IdempotencyPolicy.FORBIDDEN, + PreconditionPolicy.NONE, + CachePolicyName.standard(), + AdmissionProfileName.standard(), + ResponseProfileName.standard())); + return catalog; + } + + private static RegisteredRoute route(HttpMethodSemantic method, WebOperationName operation) { + return new RegisteredRoute("/api/v1/items/{id}", method, operation, "itemsReadHandler"); + } + + @Test + @DisplayName("a route whose operation is not in the catalog is refused") + void unregisteredOperationIsRefused() { + // A raw RouterFunction bean skips this check entirely, which is why they are not registered + // directly. + FunctionalRouteRegistry registry = + new FunctionalRouteRegistry(catalog(HttpMethodSemantic.GET, MutationKind.READ_ONLY)); + + assertThatThrownBy( + () -> + registry.register( + route(HttpMethodSemantic.GET, new WebOperationName("unregistered.operation")))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("a registered operation with a matching method is accepted") + void matchingRouteIsAccepted() { + FunctionalRouteRegistry registry = + new FunctionalRouteRegistry(catalog(HttpMethodSemantic.GET, MutationKind.READ_ONLY)); + registry.register(route(HttpMethodSemantic.GET, READ)); + + assertThat(registry.size()).isOne(); + assertThat(registry.routes()) + .singleElement() + .extracting(RegisteredRoute::handlerName) + .isEqualTo("itemsReadHandler"); + } + + @Test + @DisplayName("a route whose method disagrees with its operation is refused") + void methodMismatchIsRefused() { + // The profile's idempotency, precondition and cache policies were chosen for the declared + // method, and nothing downstream re-derives it from the route. + FunctionalRouteRegistry registry = + new FunctionalRouteRegistry(catalog(HttpMethodSemantic.POST, MutationKind.CREATE)); + + assertThatThrownBy(() -> registry.register(route(HttpMethodSemantic.GET, READ))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("nothing downstream re-derives it"); + } + + @Test + @DisplayName("two routes claiming the same method and path are refused") + void duplicateRouteIsRefused() { + // The router resolves the first match, so which one serves depends on bean ordering. + FunctionalRouteRegistry registry = + new FunctionalRouteRegistry(catalog(HttpMethodSemantic.GET, MutationKind.READ_ONLY)); + registry.register(route(HttpMethodSemantic.GET, READ)); + + assertThatThrownBy( + () -> + registry.register( + new RegisteredRoute( + "/api/v1/items/{id}", HttpMethodSemantic.GET, READ, "otherHandler"))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("depends on bean ordering"); + } + + @Test + @DisplayName("a handler must be named, because a lambda has no class name for a log") + void handlerMustBeNamed() { + assertThatThrownBy( + () -> new RegisteredRoute("/api/v1/items", HttpMethodSemantic.GET, READ, " ")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("no class name to fall back on"); + } + + @Test + @DisplayName("the router is built only from validated routes") + void routerIsBuiltFromValidatedRoutes() { + // The adapter is the only way a functional route reaches Spring. A RouterFunction bean would + // be picked up and served with no operation profile at all. + FunctionalRouteRegistry registry = + new FunctionalRouteRegistry(catalog(HttpMethodSemantic.GET, MutationKind.READ_ONLY)); + registry.register(route(HttpMethodSemantic.GET, READ)); + + assertThat( + new WebFunctionalHandlerAdapter( + registry, + r -> + request -> + org.springframework.web.reactive.function.server.ServerResponse.ok() + .build()) + .build()) + .isNotNull(); + } + + @Test + @DisplayName("an empty registry produces no router rather than an empty one") + void emptyRegistryIsRefused() { + // An empty router is a bean that serves nothing and looks installed, which is + // indistinguishable from a registry nobody populated. + FunctionalRouteRegistry registry = + new FunctionalRouteRegistry(catalog(HttpMethodSemantic.GET, MutationKind.READ_ONLY)); + + assertThatThrownBy(() -> new WebFunctionalHandlerAdapter(registry, r -> null).build()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("looks installed"); + } + + @Test + @DisplayName("a route with no handler fails at build rather than at first request") + void routeWithoutAHandlerFailsAtBuild() { + // Otherwise it is a 404 that looks like a routing bug rather than a wiring one, and it only + // appears when somebody calls it. + FunctionalRouteRegistry registry = + new FunctionalRouteRegistry(catalog(HttpMethodSemantic.GET, MutationKind.READ_ONLY)); + registry.register(route(HttpMethodSemantic.GET, READ)); + + assertThatThrownBy(() -> new WebFunctionalHandlerAdapter(registry, r -> null).build()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("resolved to nothing"); + } + + @Test + @DisplayName("a relative route pattern is refused") + void relativePatternIsRefused() { + assertThatThrownBy(() -> new RegisteredRoute("api/v1/items", HttpMethodSemantic.GET, READ, "h")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("absolute path"); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/advanced/mvc/MvcAdvancedConfigurationTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/advanced/mvc/MvcAdvancedConfigurationTest.java new file mode 100644 index 00000000..cba639f6 --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/advanced/mvc/MvcAdvancedConfigurationTest.java @@ -0,0 +1,145 @@ +package dev.caskeleton.adapter.inbound.web.advanced.mvc; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.inbound.web.advanced.stream.WebStreamPolicy; +import dev.caskeleton.adapter.inbound.web.advanced.stream.WebStreamRegistry; +import dev.caskeleton.adapter.inbound.web.advanced.stream.encoding.StreamRecordEncoder; +import dev.caskeleton.adapter.inbound.web.advanced.virtualthread.VirtualThreadAdmissionGuard; +import dev.caskeleton.adapter.inbound.web.advanced.virtualthread.VirtualThreadProfile; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.runner.WebApplicationContextRunner; +import org.springframework.core.task.AsyncTaskExecutor; +import org.springframework.core.task.TaskDecorator; + +/** + * The configurations, actually started by Spring. + * + *

Unit tests on the classes below these prove the policy is right. They prove nothing about + * whether Spring can build the beans, whether the conditions fire, or whether the graph has a cycle + * — and a configuration that never starts is the exact shape of a control that exists and is + * reached by nothing. Everything here is a failure that only appears at context refresh. + */ +@Tag("web-advanced") +class MvcAdvancedConfigurationTest { + + private final WebApplicationContextRunner runner = + new WebApplicationContextRunner() + // The executor bean requires one. That is the point of + // EVERY_TASK_EXECUTOR_BEAN_HAS_CONTEXT_DECORATOR: an executor that can be built without a + // decorator is one that silently drops request_id and trace_id on every async hop. + .withBean(TaskDecorator.class, () -> runnable -> runnable); + + @Test + @DisplayName("the executor cannot be built without a context decorator") + void executorRequiresAContextDecorator() { + // Asserted rather than assumed: an executor with no decorator loses request_id, trace_id and + // tenant_id on every hop, and a virtual-thread executor makes that worse because the carrier + // thread has no relationship to the submitter. + new WebApplicationContextRunner() + .withPropertyValues( + "backend.web.advanced.mvc-virtual-threads.enabled=true", + "backend.web.advanced.mvc-virtual-threads.admission-limit=8", + "backend.web.advanced.mvc-virtual-threads.database-pool-size=4", + "backend.web.advanced.mvc-virtual-threads.outbound-bulkhead=4") + .withUserConfiguration(VirtualThreadMvcConfiguration.class) + .run(context -> assertThat(context).hasFailed()); + } + + @Test + @DisplayName("nothing is created unless the capability is named") + void nothingIsCreatedUnlessNamed() { + runner + .withUserConfiguration( + MvcStreamingExecutorConfiguration.class, VirtualThreadMvcConfiguration.class) + .run( + context -> + assertThat(context) + .hasNotFailed() + .doesNotHaveBean(StreamRecordEncoder.class) + .doesNotHaveBean(VirtualThreadAdmissionGuard.class) + .doesNotHaveBean(AsyncTaskExecutor.class)); + } + + @Test + @DisplayName("the streaming configuration starts and wires a complete graph") + void streamingConfigurationStarts() { + // Four beans with a dependency chain between them. A cycle or a missing dependency here is not + // visible from any unit test of the classes involved. + runner + .withPropertyValues("backend.web.advanced.ndjson.enabled=true") + .withUserConfiguration(MvcStreamingExecutorConfiguration.class) + .run( + context -> + assertThat(context) + .hasNotFailed() + .hasSingleBean(WebStreamPolicy.class) + .hasSingleBean(WebStreamRegistry.class) + .hasSingleBean(StreamRecordEncoder.class) + .hasBean("mvcNdjsonWriter") + .hasBean("mvcJsonSequenceWriter")); + } + + @Test + @DisplayName("the executor and the admission limit are created together or not at all") + void executorAndLimitComeTogether() { + // A deployment that got the executor without the limit would have deleted its implicit + // concurrency bound and replaced it with nothing. + runner + .withPropertyValues( + "backend.web.advanced.mvc-virtual-threads.enabled=true", + "backend.web.advanced.mvc-virtual-threads.admission-limit=100", + "backend.web.advanced.mvc-virtual-threads.database-pool-size=20", + "backend.web.advanced.mvc-virtual-threads.outbound-bulkhead=20") + .withUserConfiguration(VirtualThreadMvcConfiguration.class) + .run( + context -> { + assertThat(context) + .hasNotFailed() + .hasSingleBean(VirtualThreadProfile.class) + .hasSingleBean(VirtualThreadAdmissionGuard.class) + .hasBean("applicationTaskExecutor"); + assertThat(context.getBean(VirtualThreadAdmissionGuard.class).limit()).isEqualTo(100); + assertThat(context.getBean(VirtualThreadProfile.class).databasePoolSize()) + .isEqualTo(20); + }); + } + + @Test + @DisplayName("the executor bean is named what Spring's async support looks for") + void executorIsNamedForSpringToFind() { + // A differently named bean is created, is never used, and leaves the container default in + // place — the capability switched on and nothing changed. + runner + .withPropertyValues( + "backend.web.advanced.mvc-virtual-threads.enabled=true", + "backend.web.advanced.mvc-virtual-threads.admission-limit=8", + "backend.web.advanced.mvc-virtual-threads.database-pool-size=4", + "backend.web.advanced.mvc-virtual-threads.outbound-bulkhead=4") + .withUserConfiguration(VirtualThreadMvcConfiguration.class) + .run( + context -> + assertThat(context.getBean("applicationTaskExecutor")) + .isInstanceOf(AsyncTaskExecutor.class)); + } + + @Test + @DisplayName("enabling virtual threads without a limit refuses to start") + void enablingWithoutALimitRefusesToStart() { + // Refused by the profile's own constructor rather than only by bean validation, so it holds + // whether or not a deployment has a validator on the classpath. The failure is a context that + // will not refresh, which is the intended outcome: a node that accepts every arrival and + // queues it on budgets that did not grow is worse than a node that does not start. + runner + .withPropertyValues("backend.web.advanced.mvc-virtual-threads.enabled=true") + .withUserConfiguration(VirtualThreadMvcConfiguration.class) + .run( + context -> + assertThat(context) + .hasFailed() + .getFailure() + .hasStackTraceContaining("admission limit")); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/advanced/mvc/MvcStreamingWiringTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/advanced/mvc/MvcStreamingWiringTest.java new file mode 100644 index 00000000..a3bd77e8 --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/advanced/mvc/MvcStreamingWiringTest.java @@ -0,0 +1,175 @@ +package dev.caskeleton.adapter.inbound.web.advanced.mvc; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.inbound.web.advanced.stream.StreamId; +import dev.caskeleton.adapter.inbound.web.advanced.stream.WebStreamEvidence; +import dev.caskeleton.adapter.inbound.web.advanced.stream.WebStreamPolicy; +import dev.caskeleton.adapter.inbound.web.advanced.stream.WebStreamTermination; +import dev.caskeleton.adapter.inbound.web.advanced.stream.encoding.NdjsonRecord; +import dev.caskeleton.adapter.inbound.web.advanced.stream.encoding.StreamFraming; +import dev.caskeleton.adapter.inbound.web.advanced.stream.encoding.StreamItemTooLargeException; +import dev.caskeleton.adapter.inbound.web.advanced.stream.encoding.StreamRecordEncoder; +import dev.caskeleton.adapter.inbound.web.json.WebObjectMapperFactory; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * The servlet-side wiring: the registry, the record writers and the disconnect classifier. + * + *

No SSE. This repository's {@code feature-streaming-response-contract} D3 refuses server-push + * streaming on the servlet stack and {@code NO_SSE_EMITTER} enforces it, so the design package's + * MVC SSE adapter is not built here — see {@code MvcStreamingExecutorConfiguration}. What is built + * is the {@code StreamingResponseBody}-shaped writer the same decision explicitly permits. + */ +@Tag("web-advanced") +class MvcStreamingWiringTest { + + private static final Instant NOW = Instant.parse("2026-08-25T10:00:00Z"); + + private static StreamRecordEncoder encoder(WebStreamPolicy policy) { + return new StreamRecordEncoder(WebObjectMapperFactory.standard(), policy); + } + + @Test + @DisplayName("every record is flushed as it is written") + void everyRecordIsFlushed() { + // A buffered streaming response arrives in one chunk at the end. It passes a body assertion + // and fails the only property the client wanted. + CountingStream output = new CountingStream(); + MvcStreamWriter writer = + new MvcStreamWriter(encoder(WebStreamPolicy.conventional()), StreamFraming.NDJSON); + + writer.write( + List.>of(NdjsonRecord.item(1, "a"), NdjsonRecord.item(2, "b")) + .iterator(), + output, + new WebStreamEvidence(new StreamId("feed"), NOW)); + + assertThat(output.flushes.get()).as("two items plus the completion marker").isEqualTo(3); + } + + @Test + @DisplayName("a completion marker is written even when the source was empty") + void emptySourceStillCompletes() { + // A zero-byte body and a connection that failed before the first record are the same thing to + // a client. + CountingStream output = new CountingStream(); + WebStreamEvidence evidence = new WebStreamEvidence(new StreamId("feed"), NOW); + MvcStreamWriter writer = + new MvcStreamWriter(encoder(WebStreamPolicy.conventional()), StreamFraming.NDJSON); + + assertThat(writer.write(List.>of().iterator(), output, evidence)) + .isEqualTo(WebStreamTermination.NORMAL_COMPLETE); + assertThat(output.text()).contains("lastSequence").endsWith("\n"); + } + + @Test + @DisplayName("a source failure after commit becomes a terminal record, not a status") + void sourceFailureBecomesATerminalRecord() { + CountingStream output = new CountingStream(); + WebStreamEvidence evidence = new WebStreamEvidence(new StreamId("feed"), NOW); + MvcStreamWriter writer = + new MvcStreamWriter(encoder(WebStreamPolicy.conventional()), StreamFraming.JSON_SEQUENCE); + + WebStreamTermination how = + writer.write(failingAfter(NdjsonRecord.item(1, "a")), output, evidence); + + assertThat(how).isEqualTo(WebStreamTermination.TERMINAL_ERROR_RECORD); + assertThat(output.text()).contains("DEPENDENCY_FAILURE"); + assertThat(evidence.termination()).contains(WebStreamTermination.TERMINAL_ERROR_RECORD); + } + + @Test + @DisplayName("a client that goes away mid-stream is not a server fault") + void clientDisconnectIsNotAServerFault() { + // The fault rate would otherwise track the tab-closing rate. + CountingStream output = new CountingStream(); + output.failAt = 1; + WebStreamEvidence evidence = new WebStreamEvidence(new StreamId("feed"), NOW); + MvcStreamWriter writer = + new MvcStreamWriter(encoder(WebStreamPolicy.conventional()), StreamFraming.NDJSON); + + assertThat( + writer.write( + List.>of(NdjsonRecord.item(1, "a")).iterator(), + output, + evidence)) + .isEqualTo(WebStreamTermination.CLIENT_DISCONNECTED); + } + + @Test + @DisplayName("an oversized item is refused before any of it is framed") + void oversizedItemIsRefusedBeforeFraming() { + // Discovering the size after the separator is on the wire leaves a truncated record the + // consumer has to resynchronise past. + WebStreamPolicy tiny = + new WebStreamPolicy( + Duration.ofSeconds(15), Duration.ofSeconds(60), Duration.ofMinutes(30), 8, 16); + + assertThatThrownBy(() -> encoder(tiny).ndjson(NdjsonRecord.item(1, "x".repeat(200)))) + .isInstanceOf(StreamItemTooLargeException.class) + .hasMessageContaining("exceeds the 16 byte ceiling"); + } + + /** A stream that counts flushes and can fail on a chosen write. */ + private static final class CountingStream extends OutputStream { + + private final ByteArrayOutputStream sink = new ByteArrayOutputStream(); + private final AtomicInteger flushes = new AtomicInteger(); + private int writes; + private int failAt = -1; + + @Override + public void write(int b) { + sink.write(b); + } + + @Override + public void write(byte[] bytes, int offset, int length) throws IOException { + if (++writes == failAt) { + throw new IOException("Broken pipe"); + } + sink.write(bytes, offset, length); + } + + @Override + public void flush() { + flushes.incrementAndGet(); + } + + String text() { + return sink.toString(StandardCharsets.UTF_8); + } + } + + private static java.util.Iterator> failingAfter(NdjsonRecord first) { + return new java.util.Iterator<>() { + private boolean served; + + @Override + public boolean hasNext() { + return true; + } + + @Override + public NdjsonRecord next() { + if (served) { + throw new IllegalStateException("the source failed"); + } + served = true; + return first; + } + }; + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/advanced/openapi/OpenApi32CompatibilityReportTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/advanced/openapi/OpenApi32CompatibilityReportTest.java new file mode 100644 index 00000000..84cb49ce --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/advanced/openapi/OpenApi32CompatibilityReportTest.java @@ -0,0 +1,110 @@ +package dev.caskeleton.adapter.inbound.web.advanced.openapi; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.inbound.web.advanced.openapi.OpenApiToolchainMatrix.ToolKind; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * Generating 3.2 alongside 3.1, without either changing the other. + * + *

The value of an API description is entirely in what consumes it, so a document in a version a + * client generator does not understand is worse than no document — it produces a client that + * compiles and is wrong. + */ +@Tag("web-advanced") +class OpenApi32CompatibilityReportTest { + + private static OpenApiToolchainMatrix fullMatrix() { + return new OpenApiToolchainMatrix() + .record(ToolKind.PARSER, "swagger-parser-2.1", true) + .record(ToolKind.LINTER, "spectral-6", true) + .record(ToolKind.GENERATOR, "openapi-generator-7", true) + .record(ToolKind.CLIENT_COMPILE, "java-17", true); + } + + @Test + @DisplayName("3.1 remains the release artifact") + void stableLaneIsTheReleaseArtifact() { + assertThat(OpenApiVersionLane.STABLE_3_1.releaseArtifact()).isTrue(); + assertThat(OpenApiVersionLane.STABLE_3_1.version()).isEqualTo("3.1.2"); + assertThat(OpenApiVersionLane.EXPERIMENTAL_3_2.releaseArtifact()).isFalse(); + assertThat(OpenApiVersionLane.EXPERIMENTAL_3_2.version()).isEqualTo("3.2.0"); + } + + @Test + @DisplayName("generating 3.2 must leave the 3.1 snapshot untouched") + void generatingDoesNotMutateTheStableSnapshot() { + // They are produced from the same model, so a contributor that mutates it on the way to 3.2 + // changes the artifact that is actually shipped. + OpenApi32CompatibilityReport clean = + new OpenApi32CompatibilityReport("sha256:aaa", "sha256:aaa", List.of(), fullMatrix()); + OpenApi32CompatibilityReport mutated = + new OpenApi32CompatibilityReport("sha256:aaa", "sha256:bbb", List.of(), fullMatrix()); + + assertThat(clean.stableArtifactUnchanged()).isTrue(); + assertThat(mutated.stableArtifactUnchanged()).isFalse(); + assertThat(mutated.promotionBlockers(true)) + .anyMatch(blocker -> blocker.contains("share mutable state")); + } + + @Test + @DisplayName("each kind of tool is checked separately") + void everyToolKindIsCheckedSeparately() { + // A linter accepts documents a parser rejects, and a generator emits a wrong-but-valid + // signature that only a compile catches. + OpenApiToolchainMatrix partial = + new OpenApiToolchainMatrix() + .record(ToolKind.PARSER, "swagger-parser-2.1", true) + .record(ToolKind.LINTER, "spectral-6", true); + + assertThat(partial.complete()).isFalse(); + assertThat(partial.gaps()) + .anyMatch(gap -> gap.startsWith("GENERATOR")) + .anyMatch(gap -> gap.startsWith("CLIENT_COMPILE")); + assertThat(fullMatrix().complete()).isTrue(); + } + + @Test + @DisplayName("a failing tool does not count as a passing one") + void failingToolDoesNotCount() { + OpenApiToolchainMatrix failed = + new OpenApiToolchainMatrix() + .record(ToolKind.PARSER, "swagger-parser-2.1", true) + .record(ToolKind.LINTER, "spectral-6", true) + .record(ToolKind.GENERATOR, "openapi-generator-7", false) + .record(ToolKind.CLIENT_COMPILE, "java-17", true); + + assertThat(failed.complete()).isFalse(); + } + + @Test + @DisplayName("3.2 stays experimental without an accepted ADR, however green the matrix is") + void adrIsRequiredRegardlessOfTheMatrix() { + OpenApi32CompatibilityReport report = + new OpenApi32CompatibilityReport("sha256:aaa", "sha256:aaa", List.of(), fullMatrix()); + + assertThat(report.promotable(false)).isFalse(); + assertThat(report.promotionBlockers(false)) + .singleElement() + .asString() + .contains("no accepted promotion ADR"); + assertThat(report.promotable(true)).isTrue(); + } + + @Test + @DisplayName("streaming description differences are recorded separately") + void streamingDifferencesAreRecorded() { + OpenApi32CompatibilityReport report = + new OpenApi32CompatibilityReport( + "sha256:aaa", + "sha256:aaa", + List.of("text/event-stream responses gain an itemSchema in 3.2"), + fullMatrix()); + + assertThat(report.streamingDescriptionDifferences()).hasSize(1); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/advanced/openapi/WebOpenApi32GeneratorTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/advanced/openapi/WebOpenApi32GeneratorTest.java new file mode 100644 index 00000000..b1bf70b4 --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/advanced/openapi/WebOpenApi32GeneratorTest.java @@ -0,0 +1,156 @@ +package dev.caskeleton.adapter.inbound.web.advanced.openapi; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.inbound.web.advanced.stream.encoding.StreamMediaType; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.Operation; +import io.swagger.v3.oas.models.PathItem; +import io.swagger.v3.oas.models.Paths; +import io.swagger.v3.oas.models.media.Content; +import io.swagger.v3.oas.models.media.MediaType; +import io.swagger.v3.oas.models.media.ObjectSchema; +import io.swagger.v3.oas.models.responses.ApiResponse; +import io.swagger.v3.oas.models.responses.ApiResponses; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * Generating 3.2 beside 3.1, and the invariant that makes it safe to run in the same build. + * + *

Both documents come from one model. If generating the experimental one mutates it, the + * artifact that actually ships changes — silently, and only in builds where this lane ran. + */ +@Tag("web-advanced") +class WebOpenApi32GeneratorTest { + + private static OpenAPI stableModel() { + MediaType sse = + new MediaType().schema(new ObjectSchema().addProperty("id", new ObjectSchema())); + OpenAPI document = new OpenAPI(); + document.setOpenapi("3.1.2"); + document.setPaths( + new Paths() + .addPathItem( + "/api/v1/orders/stream", + new PathItem() + .get( + new Operation() + .responses( + new ApiResponses() + .addApiResponse( + "200", + new ApiResponse() + .content( + new Content() + .addMediaType(StreamMediaType.SSE, sse))))))); + return document; + } + + @Test + @DisplayName("the experimental document is 3.2 and the Stable one is untouched") + void experimentalIsSeparateFromStable() { + // The invariant this class exists for. A shared model would make the shipped artifact depend + // on whether the experimental lane happened to run. + OpenAPI stable = stableModel(); + String before = stable.getOpenapi(); + + OpenAPI experimental = new WebOpenApi32Generator().generate(stable); + + assertThat(experimental.getOpenapi()).isEqualTo("3.2.0"); + assertThat(stable.getOpenapi()).isEqualTo(before).isEqualTo("3.1.2"); + assertThat(experimental).isNotSameAs(stable); + } + + @Test + @DisplayName("mutating the experimental document does not reach the Stable one") + void documentsShareNoMutableNode() { + // Not the same object is not enough: a shallow copy passes that and still shares every path, + // response and schema underneath. + OpenAPI stable = stableModel(); + OpenAPI experimental = new WebOpenApi32Generator().generate(stable); + + experimental.getPaths().remove("/api/v1/orders/stream"); + + assertThat(stable.getPaths()).containsKey("/api/v1/orders/stream"); + } + + @Test + @DisplayName("a streaming response gains a per-item schema in 3.2") + void streamingResponsesGainAnItemSchema() { + // The substantive difference for this application: 3.1 can say a response is + // text/event-stream and cannot say what one event looks like, so a generator reading it + // produces a client that treats the whole stream as one body. + WebOpenApi32Generator generator = new WebOpenApi32Generator(); + OpenAPI experimental = generator.generate(stableModel()); + + MediaType media = + experimental + .getPaths() + .get("/api/v1/orders/stream") + .getGet() + .getResponses() + .get("200") + .getContent() + .get(StreamMediaType.SSE); + + assertThat(media.getExtensions()).containsKey("x-itemSchema"); + assertThat(generator.streamingDifferences()) + .singleElement() + .asString() + .contains("text/event-stream") + .contains("treats the whole stream as one response"); + } + + @Test + @DisplayName("a non-streaming response is left alone") + void nonStreamingResponsesAreUntouched() { + OpenAPI stable = stableModel(); + stable + .getPaths() + .get("/api/v1/orders/stream") + .getGet() + .getResponses() + .get("200") + .getContent() + .addMediaType("application/json", new MediaType().schema(new ObjectSchema())); + + WebOpenApi32Generator generator = new WebOpenApi32Generator(); + OpenAPI experimental = generator.generate(stable); + + assertThat( + experimental + .getPaths() + .get("/api/v1/orders/stream") + .getGet() + .getResponses() + .get("200") + .getContent() + .get("application/json") + .getExtensions()) + .isNull(); + assertThat(generator.streamingDifferences()).hasSize(1); + } + + @Test + @DisplayName("differences are recomputed per generation, not accumulated") + void differencesDoNotAccumulate() { + // A generator reused across builds would otherwise report every difference it had ever seen, + // and the report would grow without anything changing. + WebOpenApi32Generator generator = new WebOpenApi32Generator(); + generator.generate(stableModel()); + generator.generate(stableModel()); + + assertThat(generator.streamingDifferences()).hasSize(1); + } + + @Test + @DisplayName("a document with no paths generates without failing") + void emptyDocumentGenerates() { + OpenAPI empty = new OpenAPI(); + empty.setOpenapi("3.1.2"); + + assertThat(new WebOpenApi32Generator().generate(empty).getOpenapi()).isEqualTo("3.2.0"); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/advanced/patch/JsonMergePatchApplierTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/advanced/patch/JsonMergePatchApplierTest.java new file mode 100644 index 00000000..52635b74 --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/advanced/patch/JsonMergePatchApplierTest.java @@ -0,0 +1,156 @@ +package dev.caskeleton.adapter.inbound.web.advanced.patch; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.inbound.web.json.WebObjectMapperFactory; +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import tools.jackson.databind.ObjectMapper; + +/** + * RFC 7396, and the one ambiguity that forces a tree-based implementation. + * + *

A DTO deserialized from a partial patch cannot distinguish a field the caller set to null from + * one they did not mention. Both are a null field, and the two mean opposite things. + */ +@Tag("web-advanced") +class JsonMergePatchApplierTest { + + private static final ObjectMapper MAPPER = WebObjectMapperFactory.standard(); + + /** A target with a mandatory field and two optional ones. */ + record Profile(String displayName, String description, String nickname) {} + + private static JsonMergePatchApplier applier(String... writableFields) { + return new JsonMergePatchApplier(MAPPER, PatchFieldAuthorization.allow(writableFields)); + } + + private static JsonMergePatchDocument patch(String json) { + return JsonMergePatchDocument.parse(MAPPER, json.getBytes(StandardCharsets.UTF_8)); + } + + @Test + @DisplayName("null removes a field and absence leaves it alone") + void nullRemovesAndAbsenceLeaves() { + // The defining behaviour, and the one a DTO cannot express. + PatchResult result = + applier("description", "nickname") + .apply( + new Profile("Donghyeon", "old", "dh"), + patch("{\"description\":null}"), + Profile.class); + + assertThat(result.value().description()).isNull(); + assertThat(result.value().nickname()).isEqualTo("dh"); + assertThat(result.value().displayName()).isEqualTo("Donghyeon"); + } + + @Test + @DisplayName("a field outside the allowlist is refused") + void unlistedFieldIsRefused() { + // Without an allowlist the modifiable set is whatever the type happens to have, which grows + // every time somebody adds a field. + assertThatThrownBy( + () -> + applier("description") + .apply( + new Profile("Donghyeon", "old", "dh"), + patch("{\"displayName\":\"other\"}"), + Profile.class)) + .isInstanceOf(PatchAuthorizationException.class) + .hasMessageContaining("displayName"); + } + + @Test + @DisplayName("every refused field is named, not just the first") + void allRefusedFieldsAreNamed() { + // So a caller fixing a patch does not discover the refusals one round trip at a time. + assertThatThrownBy( + () -> + applier("nickname") + .apply( + new Profile("Donghyeon", "old", "dh"), + patch("{\"displayName\":\"a\",\"description\":\"b\"}"), + Profile.class)) + .isInstanceOf(PatchAuthorizationException.class) + .hasMessageContaining("description") + .hasMessageContaining("displayName"); + } + + @Test + @DisplayName("authorization runs before anything is merged") + void authorizationRunsFirst() { + // Checking afterwards means asking whether a refused field "actually changed anything", and a + // caller who can ask that can use it to probe values they may not read. + Profile before = new Profile("Donghyeon", "old", "dh"); + assertThatThrownBy( + () -> + applier("description") + .apply(before, patch("{\"displayName\":\"Donghyeon\"}"), Profile.class)) + .isInstanceOf(PatchAuthorizationException.class); + + assertThat(before.displayName()).isEqualTo("Donghyeon"); + } + + @Test + @DisplayName("the result says which fields actually changed") + void resultReportsWhatChanged() { + // Setting a field to the value it already had is indistinguishable from changing it unless + // somebody compared, and the difference decides whether an audit entry is written. + PatchResult unchanged = + applier("description") + .apply( + new Profile("d", "same", "n"), patch("{\"description\":\"same\"}"), Profile.class); + PatchResult changed = + applier("description") + .apply(new Profile("d", "old", "n"), patch("{\"description\":\"new\"}"), Profile.class); + + assertThat(unchanged.changed()).isFalse(); + assertThat(changed.modifiedFields()).containsExactly("description"); + } + + @Test + @DisplayName("a non-object patch is refused") + void nonObjectPatchIsRefused() { + // A whole-document replacement is a PUT, and routing it through the patch path skips the + // full-document validation a PUT gets. + assertThatThrownBy(() -> patch("[1,2,3]")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("belongs on the PUT route"); + } + + @Test + @DisplayName("an over-nested patch is refused before the recursive merge runs") + void overNestedPatchIsRefused() { + // The merge is recursive, and a stack overflow inside a request thread is not something an + // exception handler can turn into a 400. + StringBuilder deep = new StringBuilder(); + for (int i = 0; i < 40; i++) { + deep.append("{\"a\":"); + } + deep.append("1"); + deep.append("}".repeat(40)); + + assertThatThrownBy(() -> patch(deep.toString())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("can turn into a 400"); + } + + @Test + @DisplayName("an empty allowlist is refused at construction") + void emptyAllowlistIsRefused() { + assertThatThrownBy(PatchFieldAuthorization::allow) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("do not expose the route"); + } + + @Test + @DisplayName("the two patch media types are not interchangeable") + void mediaTypesAreDistinct() { + assertThat(PatchMediaType.MERGE_PATCH).isEqualTo("application/merge-patch+json"); + assertThat(PatchMediaType.JSON_PATCH).isEqualTo("application/json-patch+json"); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/advanced/patch/JsonPatchApplierTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/advanced/patch/JsonPatchApplierTest.java new file mode 100644 index 00000000..0cfd3c3c --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/advanced/patch/JsonPatchApplierTest.java @@ -0,0 +1,238 @@ +package dev.caskeleton.adapter.inbound.web.advanced.patch; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.inbound.web.json.WebObjectMapperFactory; +import java.nio.charset.StandardCharsets; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import tools.jackson.databind.ObjectMapper; + +/** + * RFC 6902, and the atomicity that {@code test} exists to exploit. + * + *

A client putting a {@code test} first is relying on nothing after it having happened when the + * test fails. Every test here is one way that reliance could be broken. + */ +@Tag("web-advanced") +class JsonPatchApplierTest { + + private static final ObjectMapper MAPPER = WebObjectMapperFactory.standard(); + + /** A target with a scalar, a nested object and a list. */ + record Profile(String displayName, int version, Address address, List tags) {} + + /** A nested object, so pointer depth is exercised. */ + record Address(String city, String postcode) {} + + private static Profile profile() { + return new Profile("old", 1, new Address("Seoul", "04524"), List.of("a", "b")); + } + + private static JsonPatchApplier applier(String... writablePrefixes) { + return new JsonPatchApplier(MAPPER, JsonPointerAuthorization.allow(writablePrefixes)); + } + + private static JsonPatchDocument patch(String json) { + return JsonPatchDocument.parse(MAPPER, json.getBytes(StandardCharsets.UTF_8)); + } + + @Test + @DisplayName("a failed test leaves the target untouched") + void failedTestLeavesTargetUntouched() { + // The property the whole class exists for: a document that fails at any point applies none of + // itself. + Profile before = profile(); + + assertThatThrownBy( + () -> + applier("/displayName") + .apply( + before, + patch( + "[{\"op\":\"test\",\"path\":\"/version\",\"value\":2}," + + "{\"op\":\"replace\",\"path\":\"/displayName\",\"value\":\"new\"}]"), + Profile.class)) + .isInstanceOf(JsonPatchTestFailedException.class); + + assertThat(before.displayName()).isEqualTo("old"); + } + + @Test + @DisplayName("a test that holds lets the rest apply") + void passingTestLetsTheRestApply() { + PatchResult result = + applier("/displayName") + .apply( + profile(), + patch( + "[{\"op\":\"test\",\"path\":\"/version\",\"value\":1}," + + "{\"op\":\"replace\",\"path\":\"/displayName\",\"value\":\"new\"}]"), + Profile.class); + + assertThat(result.value().displayName()).isEqualTo("new"); + assertThat(result.modifiedFields()).containsExactly("/displayName"); + } + + @Test + @DisplayName("an authorization failure applies nothing either") + void authorizationFailureAppliesNothing() { + Profile before = profile(); + + assertThatThrownBy( + () -> + applier("/displayName") + .apply( + before, + patch( + "[{\"op\":\"replace\",\"path\":\"/displayName\",\"value\":\"new\"}," + + "{\"op\":\"replace\",\"path\":\"/version\",\"value\":9}]"), + Profile.class)) + .isInstanceOf(PatchAuthorizationException.class) + .hasMessageContaining("/version"); + + assertThat(before.displayName()).isEqualTo("old"); + } + + @Test + @DisplayName("permission on a parent covers its children, and not the reverse") + void parentPermissionCoversChildrenOnly() { + // Replacing a parent deletes every sibling, so a child permission cannot grant it. + JsonPointerAuthorization authorization = JsonPointerAuthorization.allow("/address"); + + assertThat(authorization.writable("/address")).isTrue(); + assertThat(authorization.writable("/address/city")).isTrue(); + assertThat(JsonPointerAuthorization.allow("/address/city").writable("/address")).isFalse(); + assertThat(JsonPointerAuthorization.allow("/address/city").writable("/address/postcode")) + .isFalse(); + } + + @Test + @DisplayName("a move needs write permission on its source as well as its target") + void moveNeedsPermissionOnBothEnds() { + // Checking only the destination lets a caller relocate data out of a field they may not touch. + assertThatThrownBy( + () -> + applier("/displayName") + .apply( + profile(), + patch( + "[{\"op\":\"move\",\"from\":\"/address/city\",\"path\":\"/displayName\"}]"), + Profile.class)) + .isInstanceOf(PatchAuthorizationException.class) + .hasMessageContaining("/address/city"); + } + + @Test + @DisplayName("a replace on a location that does not exist is refused") + void replaceRequiresAnExistingTarget() { + // Treating it as an add turns "change this" into "create this". + assertThatThrownBy( + () -> + applier("/address") + .apply( + profile(), + patch( + "[{\"op\":\"replace\",\"path\":\"/address/country\",\"value\":\"KR\"}]"), + Profile.class)) + .isInstanceOf(JsonPatchRejectedException.class) + .hasMessageContaining("target location to exist"); + } + + @Test + @DisplayName("an out-of-range array index is refused rather than clamped") + void outOfRangeIndexIsRefused() { + // Jackson's insert() clamps, so an index of 900 into a two-element array would silently append. + assertThatThrownBy( + () -> + applier("/tags") + .apply( + profile(), + patch("[{\"op\":\"add\",\"path\":\"/tags/900\",\"value\":\"c\"}]"), + Profile.class)) + .isInstanceOf(JsonPatchRejectedException.class) + .hasMessageContaining("out of range"); + } + + @Test + @DisplayName("appending with '-' works") + void appendingWorks() { + PatchResult result = + applier("/tags") + .apply( + profile(), + patch("[{\"op\":\"add\",\"path\":\"/tags/-\",\"value\":\"c\"}]"), + Profile.class); + + assertThat(result.value().tags()).containsExactly("a", "b", "c"); + } + + @Test + @DisplayName("an unrecognised operation is an error, not a skip") + void unrecognisedOperationIsAnError() { + // Skipping one turns a document the client believed was atomic into a partial application. + assertThatThrownBy( + () -> patch("[{\"op\":\"upsert\",\"path\":\"/displayName\",\"value\":\"x\"}]")) + .isInstanceOf(JsonPatchRejectedException.class) + .hasMessageContaining("partial application"); + } + + @Test + @DisplayName("an object body sent to the JSON Patch route is refused") + void mergePatchBodyIsRefused() { + // Merging it would report success having changed nothing. + assertThatThrownBy(() -> patch("{\"displayName\":\"x\"}")) + .isInstanceOf(JsonPatchRejectedException.class) + .hasMessageContaining("report success having changed nothing"); + } + + @Test + @DisplayName("operation count and pointer depth are both bounded") + void countAndDepthAreBounded() { + // They multiply: each operation walks its pointer over a document the server deep-copied first. + String many = + "[" + + java.util.stream.IntStream.range(0, 101) + .mapToObj(i -> "{\"op\":\"replace\",\"path\":\"/displayName\",\"value\":\"x\"}") + .collect(java.util.stream.Collectors.joining(",")) + + "]"; + + assertThatThrownBy(() -> patch(many)) + .isInstanceOf(JsonPatchRejectedException.class) + .hasMessageContaining("exceeds the 100 limit"); + assertThat(JsonPointerAuthorization.allow("/a").writable("/a" + "/b".repeat(20))).isFalse(); + } + + @Test + @DisplayName("an operation missing what its kind requires is refused at parse time") + void malformedOperationIsRefusedAtParseTime() { + // Discovered halfway through, it would be a document that has already modified the working + // copy. + assertThatThrownBy(() -> patch("[{\"op\":\"replace\",\"path\":\"/displayName\"}]")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("requires a value member"); + assertThatThrownBy(() -> patch("[{\"op\":\"move\",\"path\":\"/displayName\"}]")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("requires a from member"); + } + + @Test + @DisplayName("a move into its own source is refused") + void moveIntoOwnSourceIsRefused() { + assertThatThrownBy( + () -> patch("[{\"op\":\"move\",\"from\":\"/address\",\"path\":\"/address/self\"}]")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("inside its own source"); + } + + @Test + @DisplayName("an empty patch document is refused") + void emptyDocumentIsRefused() { + assertThatThrownBy(() -> patch("[]")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("client bug"); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/advanced/ratelimit/RateLimitDraftHeaderWriterTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/advanced/ratelimit/RateLimitDraftHeaderWriterTest.java new file mode 100644 index 00000000..ded314b6 --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/advanced/ratelimit/RateLimitDraftHeaderWriterTest.java @@ -0,0 +1,108 @@ +package dev.caskeleton.adapter.inbound.web.advanced.ratelimit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.inbound.web.ratelimit.RateLimitDecision; +import java.time.Duration; +import java.time.Instant; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * Draft headers, which are additive and never the contract. + * + *

The Stable contract is the 429 and {@code Retry-After}; both are standardised and neither + * changes when this is on. + */ +@Tag("web-advanced") +class RateLimitDraftHeaderWriterTest { + + private static final Instant NOW = Instant.parse("2026-08-25T10:00:00Z"); + + private static RateLimitDecision refused() { + return RateLimitDecision.refused(100, NOW.plusSeconds(30), Duration.ofSeconds(30)); + } + + @Test + @DisplayName("nothing is written unless the profile is explicitly enabled") + void nothingIsWrittenUnlessEnabled() { + // A deployment that turns it off behaves exactly as a Stable one, which is what the rollback + // test asserts. + assertThat( + new RateLimitDraftHeaderWriter(RateLimitDraftProfile.disabled()).write(refused(), NOW)) + .isEmpty(); + } + + @Test + @DisplayName("draft 11 writes one structured field plus a policy field") + void draft11WritesStructuredFields() { + Map headers = + new RateLimitDraftHeaderWriter( + RateLimitDraftProfile.enabled(RateLimitDraftVersion.DRAFT_11, 60)) + .write(refused(), NOW); + + assertThat(headers).containsKeys("RateLimit", "RateLimit-Policy"); + assertThat(headers.get("RateLimit")).isEqualTo("limit=100, remaining=0, reset=30"); + assertThat(headers.get("RateLimit-Policy")).isEqualTo("q=100;w=60"); + } + + @Test + @DisplayName("draft 07 writes the three separate fields instead") + void draft07WritesSeparateFields() { + // A client written against one draft silently misreads the other, which is why the version is + // named rather than assumed. + Map headers = + new RateLimitDraftHeaderWriter( + RateLimitDraftProfile.enabled(RateLimitDraftVersion.DRAFT_07, 60)) + .write(refused(), NOW); + + assertThat(headers).containsKeys("RateLimit-Limit", "RateLimit-Remaining", "RateLimit-Reset"); + assertThat(headers).doesNotContainKey("RateLimit"); + } + + @Test + @DisplayName("reset is a delta, not a timestamp") + void resetIsADelta() { + // A client whose clock is two minutes fast reads an absolute reset time as already past and + // retries immediately, which is the behaviour the header exists to prevent. + Map headers = + new RateLimitDraftHeaderWriter( + RateLimitDraftProfile.enabled(RateLimitDraftVersion.DRAFT_07, 60)) + .write(refused(), NOW.plusSeconds(10)); + + assertThat(headers.get("RateLimit-Reset")).isEqualTo("20"); + } + + @Test + @DisplayName("a reset already in the past is clamped to zero") + void pastResetIsClamped() { + // Otherwise it reads as an enormous unsigned number to a client that does not clamp. + Map headers = + new RateLimitDraftHeaderWriter( + RateLimitDraftProfile.enabled(RateLimitDraftVersion.DRAFT_07, 60)) + .write(refused(), NOW.plusSeconds(300)); + + assertThat(headers.get("RateLimit-Reset")).isEqualTo("0"); + } + + @Test + @DisplayName("the draft version is recorded for the response artifact") + void draftVersionIsRecorded() { + assertThat( + new RateLimitDraftHeaderWriter( + RateLimitDraftProfile.enabled(RateLimitDraftVersion.DRAFT_11, 60)) + .draftLabel()) + .isEqualTo("draft-11"); + } + + @Test + @DisplayName("enabling without a window is refused") + void enablingWithoutAWindowIsRefused() { + assertThatThrownBy(() -> new RateLimitDraftProfile(true, RateLimitDraftVersion.DRAFT_11, 0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("describes no policy"); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/advanced/release/WebAdvancedReleaseTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/advanced/release/WebAdvancedReleaseTest.java new file mode 100644 index 00000000..501366d4 --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/advanced/release/WebAdvancedReleaseTest.java @@ -0,0 +1,183 @@ +package dev.caskeleton.adapter.inbound.web.advanced.release; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.inbound.web.advanced.WebAdvancedFeature; +import dev.caskeleton.adapter.inbound.web.advanced.WebAdvancedFeatureFlags; +import java.time.Duration; +import java.util.Set; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** What the release has to be able to show, and what it has to be able to undo. */ +@Tag("web-advanced") +class WebAdvancedReleaseTest { + + @Test + @DisplayName("nothing is on unless it is named") + void nothingIsOnByDefault() { + WebAdvancedFeatureFlags flags = WebAdvancedFeatureFlags.none(); + + for (WebAdvancedFeature feature : WebAdvancedFeature.values()) { + assertThat(flags.enabled(feature)).isFalse(); + } + assertThat(flags.stableBehaviourPreserved()).isTrue(); + } + + @Test + @DisplayName("the capabilities that change unrelated requests are identified") + void processWideCapabilitiesAreIdentified() { + // A codec affects only requests that negotiate it; a virtual-thread executor affects every + // request in the process, and the blocking bridge affects the event loop they all share. + assertThat(WebAdvancedFeature.MVC_VIRTUAL_THREADS.affectsUnrelatedRequests()).isTrue(); + assertThat(WebAdvancedFeature.WEBFLUX_BLOCKING_BRIDGE.affectsUnrelatedRequests()).isTrue(); + assertThat(WebAdvancedFeature.CBOR.affectsUnrelatedRequests()).isFalse(); + assertThat(WebAdvancedFeatureFlags.of(WebAdvancedFeature.CBOR).stableBehaviourPreserved()) + .isTrue(); + assertThat( + WebAdvancedFeatureFlags.of(WebAdvancedFeature.MVC_VIRTUAL_THREADS) + .stableBehaviourPreserved()) + .isFalse(); + } + + @Test + @DisplayName("each capability has its own flag property") + void everyCapabilityHasItsOwnProperty() { + assertThat(WebAdvancedFeature.JSON_MERGE_PATCH.propertyName()) + .isEqualTo("backend.web.advanced.json-merge-patch.enabled"); + assertThat( + java.util.Arrays.stream(WebAdvancedFeature.values()) + .map(WebAdvancedFeature::propertyName) + .distinct() + .count()) + .isEqualTo(WebAdvancedFeature.values().length); + } + + @Test + @DisplayName("streaming and virtual threads soak longer than a codec does") + void soakScalesWithTheFailureMode() { + assertThat(WebAdvancedPromotionGate.forFeature(WebAdvancedFeature.SSE).minimumSoak()) + .isEqualTo(Duration.ofHours(24)); + assertThat( + WebAdvancedPromotionGate.forFeature(WebAdvancedFeature.MVC_VIRTUAL_THREADS) + .minimumSoak()) + .isEqualTo(Duration.ofHours(24)); + assertThat(WebAdvancedPromotionGate.forFeature(WebAdvancedFeature.CBOR).minimumSoak()) + .isEqualTo(Duration.ofHours(8)); + } + + @Test + @DisplayName("each capability requires the suites its own failures need") + void suitesMatchTheFailureMode() { + assertThat(WebAdvancedPromotionGate.forFeature(WebAdvancedFeature.SSE).requiredSuites()) + .contains("streaming-soak-10k", "slow-consumer-bounded", "pod-drain"); + assertThat( + WebAdvancedPromotionGate.forFeature(WebAdvancedFeature.MVC_VIRTUAL_THREADS) + .requiredSuites()) + .contains("virtual-thread-admission", "pinning-jfr"); + assertThat(WebAdvancedPromotionGate.forFeature(WebAdvancedFeature.XML).requiredSuites()) + .contains("codec-security"); + } + + @Test + @DisplayName("the parser and patch capabilities need a security review") + void parsersAndPatchesNeedASecurityReview() { + // Each takes attacker-supplied bytes into a new parser, or lets a caller address arbitrary + // parts of a resource. + assertThat(WebAdvancedPromotionGate.needsSecurityReview(WebAdvancedFeature.XML)).isTrue(); + assertThat(WebAdvancedPromotionGate.needsSecurityReview(WebAdvancedFeature.JSON_PATCH)) + .isTrue(); + assertThat(WebAdvancedPromotionGate.needsSecurityReview(WebAdvancedFeature.SSE)).isFalse(); + } + + @Test + @DisplayName("promotion is blocked until rollback restores Stable behaviour exactly") + void rollbackMustRestoreStableBehaviour() { + // If turning the feature off does not restore Stable behaviour, the feature was never optional + // and every deployment has it. + WebAdvancedPromotionGate gate = + new WebAdvancedPromotionGate(Set.of("web:test"), Duration.ofHours(8), true, true, false); + + assertThat(gate.blockers(WebAdvancedFeature.SSE, Set.of("web:test"), Duration.ofHours(9))) + .singleElement() + .asString() + .contains("every deployment has it"); + } + + @Test + @DisplayName("blockers are listed, not collapsed to a boolean") + void blockersAreEnumerated() { + WebAdvancedPromotionGate gate = + new WebAdvancedPromotionGate( + Set.of("web:test", "codec-security"), Duration.ofHours(8), false, false, false); + + assertThat(gate.blockers(WebAdvancedFeature.XML, Set.of("web:test"), Duration.ofHours(1))) + .hasSize(5) + .anyMatch(line -> line.contains("codec-security")) + .anyMatch(line -> line.contains("short of the required")) + .anyMatch(line -> line.contains("no security review")) + .anyMatch(line -> line.contains("rollback not exercised")); + } + + @Test + @DisplayName("a fully satisfied gate promotes") + void satisfiedGatePromotes() { + WebAdvancedPromotionGate gate = + new WebAdvancedPromotionGate(Set.of("web:test"), Duration.ofHours(8), true, true, true); + + assertThat(gate.promotable(WebAdvancedFeature.SSE, Set.of("web:test"), Duration.ofHours(8))) + .isTrue(); + } + + @Test + @DisplayName("the manifest reports what is required and absent, not just what is present") + void manifestReportsWhatIsMissing() { + // A manifest that only listed what was collected would always look complete: it lists what it + // lists. + WebAdvancedReleaseManifest manifest = + WebAdvancedReleaseManifest.builder() + .record("stable-release-baseline", "run/1") + .record("streaming-soak-10k", "run/2") + .build(); + + assertThat(manifest.has("stable-release-baseline")).isTrue(); + assertThat(manifest.reference("streaming-soak-10k")).contains("run/2"); + assertThat(manifest.complete()).isFalse(); + assertThat(manifest.missing()) + .contains("slow-consumer-bounded", "patch-security", "rollback-disabled-profile"); + } + + @Test + @DisplayName("a complete manifest carries every required piece") + void completeManifestCarriesEverything() { + WebAdvancedReleaseManifest.Builder builder = WebAdvancedReleaseManifest.builder(); + WebAdvancedReleaseManifest.REQUIRED.forEach(name -> builder.record(name, "run/" + name)); + + assertThat(builder.build().complete()).isTrue(); + assertThat(builder.build().missing()).isEmpty(); + } + + @Test + @DisplayName("evidence without a reference is refused") + void referencelessEvidenceIsRefused() { + // A name with no reference is a claim, and a manifest of claims is what this exists instead of. + assertThatThrownBy(() -> WebAdvancedReleaseManifest.builder().record("patch-security", " ")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("manifest of claims"); + } + + @Test + @DisplayName("a gate requiring no suite or no soak is refused") + void emptyGateIsRefused() { + assertThatThrownBy( + () -> new WebAdvancedPromotionGate(Set.of(), Duration.ofHours(1), true, true, true)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("reads as one"); + assertThatThrownBy( + () -> new WebAdvancedPromotionGate(Set.of("a"), Duration.ZERO, true, true, true)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("needs load or time to appear"); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/advanced/release/WebAdvancedRollbackIT.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/advanced/release/WebAdvancedRollbackIT.java new file mode 100644 index 00000000..f06e6c41 --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/advanced/release/WebAdvancedRollbackIT.java @@ -0,0 +1,158 @@ +package dev.caskeleton.adapter.inbound.web.advanced.release; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.inbound.web.advanced.WebAdvancedFeature; +import dev.caskeleton.adapter.inbound.web.advanced.codec.RepresentationNegotiationPolicy; +import dev.caskeleton.adapter.inbound.web.advanced.codec.WebRepresentation; +import dev.caskeleton.adapter.inbound.web.advanced.functional.FunctionalRouteRegistry; +import dev.caskeleton.adapter.inbound.web.advanced.mvc.MvcStreamingExecutorConfiguration; +import dev.caskeleton.adapter.inbound.web.advanced.mvc.VirtualThreadMvcConfiguration; +import dev.caskeleton.adapter.inbound.web.advanced.ratelimit.RateLimitDraftHeaderWriter; +import dev.caskeleton.adapter.inbound.web.advanced.ratelimit.RateLimitDraftProfile; +import dev.caskeleton.adapter.inbound.web.advanced.stream.WebStreamPolicy; +import dev.caskeleton.adapter.inbound.web.advanced.stream.WebStreamRegistry; +import dev.caskeleton.adapter.inbound.web.advanced.stream.encoding.StreamRecordEncoder; +import dev.caskeleton.adapter.inbound.web.advanced.virtualthread.VirtualThreadAdmissionGuard; +import dev.caskeleton.adapter.inbound.web.ratelimit.RateLimitDecision; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.runner.WebApplicationContextRunner; +import org.springframework.core.task.AsyncTaskExecutor; +import org.springframework.core.task.TaskDecorator; + +/** + * Turning every Advanced capability off restores Stable behaviour exactly. + * + *

The condition the promotion gate refuses to waive, asserted rather than asserted about. If + * disabling a feature does not restore Stable behaviour then the feature was never optional, and + * every deployment that chose not to enable it has it anyway — which makes the whole + * Advanced/Stable separation a naming convention. + * + *

Two of the twelve capabilities change requests that do not use them. Those are the ones this + * matters most for, and they are the ones whose configurations are started here with nothing set. + */ +@Tag("web-advanced") +class WebAdvancedRollbackIT { + + private final WebApplicationContextRunner runner = + new WebApplicationContextRunner().withBean(TaskDecorator.class, () -> runnable -> runnable); + + @Test + @DisplayName("with no flag set, no Advanced bean is created") + void nothingIsCreatedWithNoFlags() { + // No property values at all — the shape a Stable deployment actually has. + runner + .withUserConfiguration( + MvcStreamingExecutorConfiguration.class, VirtualThreadMvcConfiguration.class) + .run( + context -> + assertThat(context) + .hasNotFailed() + .doesNotHaveBean(WebStreamPolicy.class) + .doesNotHaveBean(WebStreamRegistry.class) + .doesNotHaveBean(StreamRecordEncoder.class) + .doesNotHaveBean(VirtualThreadAdmissionGuard.class) + .doesNotHaveBean(AsyncTaskExecutor.class)); + } + + @Test + @DisplayName("a flag explicitly set to false creates nothing either") + void explicitFalseCreatesNothing() { + // Distinct from absence, and worth its own case: a condition written with + // matchIfMissing = true would pass the test above and fail this one. + runner + .withPropertyValues( + "backend.web.advanced.ndjson.enabled=false", + "backend.web.advanced.mvc-virtual-threads.enabled=false") + .withUserConfiguration( + MvcStreamingExecutorConfiguration.class, VirtualThreadMvcConfiguration.class) + .run( + context -> + assertThat(context) + .hasNotFailed() + .doesNotHaveBean(StreamRecordEncoder.class) + .doesNotHaveBean(VirtualThreadAdmissionGuard.class)); + } + + @Test + @DisplayName("the flag set reports that Stable behaviour is preserved") + void flagsReportStablePreservation() { + assertThat( + dev.caskeleton.adapter.inbound.web.advanced.WebAdvancedFeatureFlags.none() + .stableBehaviourPreserved()) + .isTrue(); + for (WebAdvancedFeature feature : WebAdvancedFeature.values()) { + boolean preserved = + dev.caskeleton.adapter.inbound.web.advanced.WebAdvancedFeatureFlags.of(feature) + .stableBehaviourPreserved(); + assertThat(preserved) + .as("%s claims to leave unrelated requests alone", feature) + .isEqualTo(!feature.affectsUnrelatedRequests()); + } + } + + @Test + @DisplayName("with the codecs off, only JSON negotiates") + void codecsOffLeaveOnlyJson() { + // A Stable deployment answers exactly one representation, whatever a client asks for. + RepresentationNegotiationPolicy stable = RepresentationNegotiationPolicy.jsonOnly(); + Set produces = + Set.of(WebRepresentation.JSON, WebRepresentation.CBOR, WebRepresentation.XML); + + assertThat(stable.negotiate(List.of("application/json"), produces, Optional.of("mobile-app"))) + .contains(WebRepresentation.JSON); + assertThat(stable.negotiate(List.of("application/cbor"), produces, Optional.of("mobile-app"))) + .isEmpty(); + assertThat(stable.negotiate(List.of("application/xml"), produces, Optional.of("mobile-app"))) + .isEmpty(); + } + + @Test + @DisplayName("with the draft headers off, the response carries none of them") + void draftHeadersOffWriteNothing() { + // The Stable contract is the 429 and Retry-After. This capability is additive, so disabling it + // must leave the response byte-identical. + RateLimitDecision refused = + RateLimitDecision.refused( + 100, Instant.parse("2026-08-25T10:00:30Z"), Duration.ofSeconds(30)); + + assertThat( + new RateLimitDraftHeaderWriter(RateLimitDraftProfile.disabled()) + .write(refused, Instant.parse("2026-08-25T10:00:00Z"))) + .isEmpty(); + } + + @Test + @DisplayName("with functional routing off, an empty registry serves nothing") + void functionalRoutingOffServesNothing() { + // Not "an empty router": the adapter refuses to build one, so a deployment that enabled the + // capability and registered nothing fails at startup rather than serving 404s. + assertThat( + new FunctionalRouteRegistry( + new dev.caskeleton.adapter.inbound.web.operation.InMemoryWebOperationCatalog()) + .size()) + .isZero(); + } + + @Test + @DisplayName("every capability's promotion gate demands the rollback evidence") + void everyGateDemandsRollbackEvidence() { + // The machine-checked half of what this class demonstrates by hand. + for (WebAdvancedFeature feature : WebAdvancedFeature.values()) { + WebAdvancedPromotionGate gate = WebAdvancedPromotionGate.forFeature(feature); + assertThat(gate.rollbackValidated()).as("%s starts un-promotable", feature).isFalse(); + assertThat(gate.stableBehaviourUnchanged()).isFalse(); + assertThat(gate.blockers(feature, gate.requiredSuites(), gate.minimumSoak())) + .as("%s blocks on rollback and on Stable behaviour", feature) + .anyMatch(blocker -> blocker.contains("rollback not exercised")) + .anyMatch(blocker -> blocker.contains("every deployment has it")); + } + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/advanced/release/WebStreamingSoakIT.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/advanced/release/WebStreamingSoakIT.java new file mode 100644 index 00000000..75d9d87f --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/advanced/release/WebStreamingSoakIT.java @@ -0,0 +1,208 @@ +package dev.caskeleton.adapter.inbound.web.advanced.release; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.inbound.web.advanced.stream.StreamId; +import dev.caskeleton.adapter.inbound.web.advanced.stream.StreamSequence; +import dev.caskeleton.adapter.inbound.web.advanced.stream.WebStreamDrainCoordinator; +import dev.caskeleton.adapter.inbound.web.advanced.stream.WebStreamEvidence; +import dev.caskeleton.adapter.inbound.web.advanced.stream.WebStreamRegistry; +import dev.caskeleton.adapter.inbound.web.advanced.stream.WebStreamSession; +import dev.caskeleton.adapter.inbound.web.advanced.stream.WebStreamTermination; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * The registry and the drain under the concurrency they are sized for. + * + *

Every claim these two make is about what happens at scale, and none of it survives being + * asserted with three sessions on one thread. A registry whose admission check is not atomic admits + * more than its ceiling only when two arrivals race; a drain that releases slots twice only drifts + * when a cancel meets a completion. Both need contention to appear at all. + * + *

Tagged separately so a pull request does not pay for it. What this is not is a + * ten-thousand-connection soak against a running server: that needs a deployed node, real sockets + * and hours, and it stays in the nightly lane's remit. This is the in-process half — the data + * structures under concurrent load — which is where the bounds actually live. + */ +@Tag("web-advanced") +@Tag("web-advanced-soak") +class WebStreamingSoakIT { + + private static final Instant NOW = Instant.parse("2026-08-25T10:00:00Z"); + private static final int CEILING = 1_000; + private static final int ARRIVALS = 4_000; + + @Test + @DisplayName("concurrent arrivals never exceed the node's ceiling") + void concurrentArrivalsRespectTheCeiling() throws Exception { + // The property that only fails under a race. A check-then-insert admission passes every + // sequential test and admits ceiling + writers under contention, which in production is a node + // holding more connections than it was sized for. + WebStreamRegistry registry = new WebStreamRegistry(CEILING); + AtomicInteger admitted = new AtomicInteger(); + CountDownLatch start = new CountDownLatch(1); + + try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) { + List> futures = new ArrayList<>(); + for (int i = 0; i < ARRIVALS; i++) { + int index = i; + futures.add( + executor.submit( + () -> { + start.await(); + if (registry.register(new SoakSession("s-" + index))) { + admitted.incrementAndGet(); + } + return null; + })); + } + start.countDown(); + for (Future future : futures) { + future.get(60, TimeUnit.SECONDS); + } + } + + assertThat(registry.activeStreams()).isEqualTo(CEILING); + assertThat(admitted.get()).isEqualTo(CEILING); + } + + @Test + @DisplayName("a drain under load closes every stream and returns every slot") + void drainUnderLoadClosesEverything() throws Exception { + // A slot released twice drifts the count below the truth, and the node then admits more than + // it has capacity for. It only happens when a cancel races a completion, which is exactly what + // a drain of a thousand live streams produces. + WebStreamRegistry registry = new WebStreamRegistry(CEILING); + List sessions = new ArrayList<>(); + for (int i = 0; i < CEILING; i++) { + SoakSession session = new SoakSession("s-" + i); + sessions.add(session); + registry.register(session); + } + AtomicLong clock = new AtomicLong(); + WebStreamDrainCoordinator coordinator = + new WebStreamDrainCoordinator(registry, () -> clock.addAndGet(1_000)); + + // Half the streams end on their own while the drain is running — the race the release guard + // exists for. + List> selfClosing = new ArrayList<>(); + try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) { + for (int i = 0; i < CEILING; i += 2) { + SoakSession session = sessions.get(i); + selfClosing.add( + executor.submit( + () -> { + session.forceClose(WebStreamTermination.NORMAL_COMPLETE); + registry.deregister(session.streamId()); + return null; + })); + } + int forced = coordinator.beginDrain(Duration.ofSeconds(10), 0); + assertThat(forced).isBetween(0, CEILING); + for (Future future : selfClosing) { + future.get(60, TimeUnit.SECONDS); + } + } + + assertThat(coordinator.accepting()).isFalse(); + assertThat(registry.activeStreams()).isZero(); + assertThat(sessions).allMatch(session -> !session.open()); + assertThat(sessions).allMatch(session -> session.reconnectRequested.get() || !session.open()); + } + + @Test + @DisplayName("evidence stays consistent while one stream is written from many threads") + void evidenceIsConsistentUnderConcurrentDelivery() throws Exception { + // The monotonicity check is the only thing standing between a duplicated position and a client + // that silently drops an item. Under contention an unsynchronised counter produces exactly + // that, and never in a single-threaded test. + WebStreamEvidence evidence = new WebStreamEvidence(new StreamId("soak"), NOW); + AtomicInteger accepted = new AtomicInteger(); + AtomicInteger refused = new AtomicInteger(); + CountDownLatch start = new CountDownLatch(1); + int writers = 64; + int perWriter = 100; + + try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) { + List> futures = new ArrayList<>(); + for (int w = 0; w < writers; w++) { + int writer = w; + futures.add( + executor.submit( + () -> { + start.await(); + for (int i = 0; i < perWriter; i++) { + try { + evidence.recordDelivered( + new StreamSequence((long) writer * perWriter + i + 1)); + accepted.incrementAndGet(); + } catch (IllegalStateException outOfOrder) { + refused.incrementAndGet(); + } + } + return null; + })); + } + start.countDown(); + for (Future future : futures) { + future.get(60, TimeUnit.SECONDS); + } + } + + // Every position is distinct, so the count is exactly what was accepted — never more. + assertThat(evidence.deliveredCount()).isEqualTo(accepted.get()); + assertThat(accepted.get() + refused.get()).isEqualTo(writers * perWriter); + assertThat(evidence.lastDelivered()).isPresent(); + } + + /** A session that records what the drain asked of it. */ + private static final class SoakSession implements WebStreamSession { + + private final StreamId id; + private final AtomicBoolean closed = new AtomicBoolean(); + private final AtomicBoolean reconnectRequested = new AtomicBoolean(); + + SoakSession(String id) { + this.id = new StreamId(id); + } + + @Override + public StreamId streamId() { + return id; + } + + @Override + public Instant startedAt() { + return NOW; + } + + @Override + public void requestReconnect() { + reconnectRequested.set(true); + } + + @Override + public void forceClose(WebStreamTermination reason) { + closed.set(true); + } + + @Override + public boolean open() { + return !closed.get(); + } + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/advanced/stream/WebStreamCoreTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/advanced/stream/WebStreamCoreTest.java new file mode 100644 index 00000000..52283a6a --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/advanced/stream/WebStreamCoreTest.java @@ -0,0 +1,214 @@ +package dev.caskeleton.adapter.inbound.web.advanced.stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.inbound.web.error.ProblemCode; +import java.time.Duration; +import java.time.Instant; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * What a streaming response can and cannot say once its headers are written. + * + *

The single fact everything here follows from: after the first byte the status is 200 and + * cannot change. A stream that ends because a dependency failed and one that ends because it + * finished are the same thing at the transport layer. + */ +@Tag("web-advanced") +class WebStreamCoreTest { + + private static final Instant NOW = Instant.parse("2026-08-25T10:00:00Z"); + private static final StreamId STREAM = new StreamId("orders-feed"); + + @Test + @DisplayName("a committed response never tries to change its HTTP status") + void committedResponseNeverChangesStatus() { + // Writing a problem document onto a committed response produces a body that is half stream and + // half JSON, which no client parses and every proxy caches as a success. + TerminationDecision decision = + WebStreamTerminationMapper.standard().mapFailure(true, new IllegalStateException("boom")); + + assertThat(decision.termination()).contains(WebStreamTermination.TERMINAL_ERROR_RECORD); + assertThat(decision.httpStatusChange()).isEmpty(); + } + + @Test + @DisplayName("an uncommitted failure is still an ordinary HTTP error") + void uncommittedFailureIsAnHttpError() { + TerminationDecision decision = + WebStreamTerminationMapper.standard().mapFailure(false, new IllegalStateException("boom")); + + assertThat(decision.httpStatusChange()).contains(ProblemCode.DEPENDENCY_FAILURE); + assertThat(decision.termination()).isEmpty(); + } + + @Test + @DisplayName("a decision is one or the other, never both and never neither") + void decisionsAreWellFormed() { + assertThatThrownBy( + () -> + new TerminationDecision( + java.util.Optional.of(WebStreamTermination.MAX_AGE), + java.util.Optional.of(ProblemCode.DEPENDENCY_FAILURE), + "x")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("never both and never neither"); + } + + @Test + @DisplayName("a connection that fails before the terminal record is an abrupt close") + void writeFailureIsAnAbruptClose() { + // Counting these as normal completions is how a rising rate of mid-stream failures stays + // invisible. + TerminationDecision decision = WebStreamTerminationMapper.standard().mapWriteFailure(); + + assertThat(decision.termination()).contains(WebStreamTermination.ABRUPT_CLOSE); + assertThat(WebStreamTermination.ABRUPT_CLOSE.clientWasTold()).isFalse(); + assertThat(WebStreamTermination.NORMAL_COMPLETE.clientWasTold()).isTrue(); + } + + @Test + @DisplayName("stream positions must advance") + void positionsMustAdvance() { + // A repeated position means the source duplicated or the writer retried, and a client + // deduplicating on position would silently drop the second item. + WebStreamEvidence evidence = new WebStreamEvidence(STREAM, NOW); + evidence.recordDelivered(new StreamSequence(1)); + evidence.recordDelivered(new StreamSequence(2)); + + assertThatThrownBy(() -> evidence.recordDelivered(new StreamSequence(2))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("would silently drop this item"); + assertThatThrownBy(() -> evidence.recordDelivered(new StreamSequence(1))) + .isInstanceOf(IllegalStateException.class); + assertThat(evidence.deliveredCount()).isEqualTo(2); + } + + @Test + @DisplayName("nothing is delivered after the stream ended") + void nothingIsDeliveredAfterTheEnd() { + WebStreamEvidence evidence = new WebStreamEvidence(STREAM, NOW); + evidence.recordDelivered(new StreamSequence(1)); + evidence.recordTermination(WebStreamTermination.NORMAL_COMPLETE, NOW.plusSeconds(1)); + + assertThatThrownBy(() -> evidence.recordDelivered(new StreamSequence(2))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("already told the client there is nothing more"); + } + + @Test + @DisplayName("the evidence records whether the client could tell the stream ended") + void evidenceRecordsWhetherTheClientWasTold() { + WebStreamEvidence told = new WebStreamEvidence(STREAM, NOW); + told.recordTermination(WebStreamTermination.NORMAL_COMPLETE, NOW); + WebStreamEvidence cut = new WebStreamEvidence(STREAM, NOW); + cut.recordTermination(WebStreamTermination.ABRUPT_CLOSE, NOW); + + assertThat(told.clientKnowsItEnded()).isTrue(); + assertThat(cut.clientKnowsItEnded()).isFalse(); + assertThat(cut.ageAt(NOW.plusSeconds(30))).isZero(); + } + + @Test + @DisplayName("a position of zero is refused") + void positionZeroIsRefused() { + // A stream whose first item claims position 0 and one whose sequence was never set look + // identical. + assertThatThrownBy(() -> new StreamSequence(0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("cannot pass for the first item"); + } + + @Test + @DisplayName("a stream id is constrained because it reaches logs and metric tags") + void streamIdIsConstrained() { + assertThat(new StreamId("orders-feed").value()).isEqualTo("orders-feed"); + assertThatThrownBy(() -> new StreamId("has spaces")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new StreamId("-leading-dash")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new StreamId("x".repeat(65))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("a heartbeat slower than the idle timeout is refused") + void selfDefeatingPolicyIsRefused() { + // Otherwise the server times out its own healthy streams between beats. + assertThatThrownBy( + () -> + new WebStreamPolicy( + Duration.ofSeconds(60), + Duration.ofSeconds(30), + Duration.ofMinutes(10), + 8, + 1024)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("times out its own healthy streams"); + } + + @Test + @DisplayName("an unreachable idle timeout is refused") + void unreachableIdleTimeoutIsRefused() { + assertThatThrownBy( + () -> + new WebStreamPolicy( + Duration.ofSeconds(5), Duration.ofMinutes(20), Duration.ofMinutes(10), 8, 1024)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("reads as protection that is not there"); + } + + @Test + @DisplayName("the policy bounds buffering and item size") + void policyBoundsBufferingAndSize() { + WebStreamPolicy policy = WebStreamPolicy.conventional(); + + assertThat(policy.mayBuffer(255)).isTrue(); + assertThat(policy.mayBuffer(256)).isFalse(); + assertThat(policy.itemWithinBounds(262_144)).isTrue(); + assertThat(policy.itemWithinBounds(262_145)).isFalse(); + } + + @Test + @DisplayName("a stream that delivered nothing may be retried whole") + void nothingDeliveredMeansRetryWhole() { + // A retry after 400 items re-delivers all 400, and the client has no way to know they are + // repeats unless it tracked positions. + WebStreamErrorPolicy policy = WebStreamErrorPolicy.startOver(); + + assertThat(policy.afterDelivering(WebStreamTermination.ABRUPT_CLOSE, 0).retryWholeStream()) + .isTrue(); + assertThat(policy.afterDelivering(WebStreamTermination.ABRUPT_CLOSE, 400).retryWholeStream()) + .isFalse(); + assertThat( + WebStreamErrorPolicy.resumeFromPosition() + .afterDelivering(WebStreamTermination.ABRUPT_CLOSE, 400) + .resumable()) + .isTrue(); + } + + @Test + @DisplayName("resuming and retrying whole are mutually exclusive") + void resumeAndRetryAreExclusive() { + assertThatThrownBy(() -> new WebStreamErrorPolicy(true, true)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("cannot tell the repeats apart"); + } + + @Test + @DisplayName("an envelope says which of the three outcomes it is") + void envelopesAreThreeWay() { + assertThat(new WebStreamEnvelope.Item<>(STREAM, StreamSequence.first(), "x").terminal()) + .isFalse(); + assertThat(new WebStreamEnvelope.Complete(STREAM, StreamSequence.first()).terminal()) + .isTrue(); + assertThat( + new WebStreamEnvelope.Failure( + STREAM, StreamSequence.first(), ProblemCode.DEPENDENCY_FAILURE, "failed") + .terminal()) + .isTrue(); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/advanced/stream/WebStreamDrainTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/advanced/stream/WebStreamDrainTest.java new file mode 100644 index 00000000..c8dda742 --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/advanced/stream/WebStreamDrainTest.java @@ -0,0 +1,155 @@ +package dev.caskeleton.adapter.inbound.web.advanced.stream; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicLong; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** Shutting streams down without every client reconnecting at the same instant. */ +@Tag("web-advanced") +class WebStreamDrainTest { + + private static final Instant NOW = Instant.parse("2026-08-25T10:00:00Z"); + + @Test + @DisplayName("a drain stops accepting before it starts draining") + void drainStopsAcceptingFirst() { + // If readiness stays up while streams drain, the load balancer keeps sending new ones and the + // drain never finishes. + WebStreamRegistry registry = new WebStreamRegistry(8); + FakeSession first = new FakeSession("a"); + FakeSession second = new FakeSession("b"); + registry.register(first); + registry.register(second); + AtomicLong clock = new AtomicLong(); + WebStreamDrainCoordinator coordinator = + new WebStreamDrainCoordinator(registry, () -> clock.addAndGet(1_000)); + + assertThat(coordinator.accepting()).isTrue(); + coordinator.beginDrain(Duration.ofSeconds(5), 0); + + assertThat(coordinator.accepting()).isFalse(); + assertThat(registry.activeStreams()).isZero(); + } + + @Test + @DisplayName("clients are asked to reconnect before they are cut") + void clientsAreAskedBeforeBeingCut() { + // A client told to reconnect goes elsewhere in an orderly way. One whose socket is cut retries + // immediately, and if every socket is cut at once, every client retries at once. + WebStreamRegistry registry = new WebStreamRegistry(8); + FakeSession session = new FakeSession("a"); + registry.register(session); + AtomicLong clock = new AtomicLong(); + new WebStreamDrainCoordinator(registry, () -> clock.addAndGet(10_000)) + .beginDrain(Duration.ofSeconds(1), 0); + + assertThat(session.reconnectRequested).isTrue(); + } + + @Test + @DisplayName("the drain has a deadline and forces what is left") + void drainHasADeadline() { + // A drain without one never completes, because there is always a client that does not + // reconnect. + WebStreamRegistry registry = new WebStreamRegistry(8); + FakeSession stubborn = new FakeSession("a"); + registry.register(stubborn); + AtomicLong clock = new AtomicLong(); + int forced = + new WebStreamDrainCoordinator(registry, () -> clock.addAndGet(10_000)) + .beginDrain(Duration.ofSeconds(1), 0); + + assertThat(forced).isOne(); + assertThat(stubborn.forcedWith).isEqualTo(WebStreamTermination.ABRUPT_CLOSE); + } + + @Test + @DisplayName("the registry bounds how many streams one node holds") + void registryBoundsConcurrentStreams() { + // A node with a hundred open streams and no other traffic looks idle by request rate, and is + // holding a hundred connections, buffers and subscriptions. + WebStreamRegistry registry = new WebStreamRegistry(2); + + assertThat(registry.register(new FakeSession("a"))).isTrue(); + assertThat(registry.register(new FakeSession("b"))).isTrue(); + assertThat(registry.register(new FakeSession("c"))).isFalse(); + assertThat(registry.activeStreams()).isEqualTo(2); + } + + @Test + @DisplayName("a stream that ends frees its slot") + void endedStreamFreesItsSlot() { + WebStreamRegistry registry = new WebStreamRegistry(1); + registry.register(new FakeSession("a")); + + assertThat(registry.register(new FakeSession("b"))).isFalse(); + assertThat(registry.deregister(new StreamId("a"))).isTrue(); + assertThat(registry.register(new FakeSession("b"))).isTrue(); + } + + @Test + @DisplayName("streams past their maximum age are identifiable") + void agedStreamsAreIdentifiable() { + WebStreamRegistry registry = new WebStreamRegistry(8); + registry.register(new FakeSession("old", NOW)); + registry.register(new FakeSession("new", NOW.plus(Duration.ofMinutes(20)))); + + List aged = + registry.olderThan(Duration.ofMinutes(30), NOW.plus(Duration.ofMinutes(31))); + + assertThat(aged).extracting(session -> session.streamId().value()).containsExactly("old"); + } + + /** A session that records what was asked of it. */ + private static final class FakeSession implements WebStreamSession { + + private final StreamId id; + private final Instant startedAt; + private boolean reconnectRequested; + private WebStreamTermination forcedWith; + private final List events = new ArrayList<>(); + + FakeSession(String id) { + this(id, NOW); + } + + FakeSession(String id, Instant startedAt) { + this.id = new StreamId(id); + this.startedAt = startedAt; + } + + @Override + public StreamId streamId() { + return id; + } + + @Override + public Instant startedAt() { + return startedAt; + } + + @Override + public void requestReconnect() { + reconnectRequested = true; + events.add("reconnect"); + } + + @Override + public void forceClose(WebStreamTermination reason) { + forcedWith = reason; + events.add("force"); + } + + @Override + public boolean open() { + return forcedWith == null; + } + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/advanced/stream/encoding/StreamFramingTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/advanced/stream/encoding/StreamFramingTest.java new file mode 100644 index 00000000..78f7deee --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/advanced/stream/encoding/StreamFramingTest.java @@ -0,0 +1,125 @@ +package dev.caskeleton.adapter.inbound.web.advanced.stream.encoding; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * The framing, and what each format does when a stream is cut mid-write. + * + *

Which for a long-lived stream is the normal way it ends, not an edge case. + */ +@Tag("web-advanced") +class StreamFramingTest { + + private static byte[] utf8(String value) { + return value.getBytes(StandardCharsets.UTF_8); + } + + @Test + @DisplayName("every NDJSON record ends with exactly one newline") + void ndjsonRecordsEndWithOneNewline() { + ByteArrayOutputStream body = new ByteArrayOutputStream(); + body.writeBytes(NdjsonFraming.frame(utf8("{\"id\":\"a\"}"))); + body.writeBytes(NdjsonFraming.frame(utf8("{\"complete\":1}"))); + + String text = body.toString(StandardCharsets.UTF_8); + assertThat(text).endsWith("\n"); + assertThat(text.lines()).hasSize(2); + assertThat(NdjsonFraming.recordCount(body.toByteArray())).isEqualTo(2); + } + + @Test + @DisplayName("a record containing a newline is refused") + void embeddedNewlineIsRefused() { + // The consumer's line split is the only record boundary there is, so one pretty-printed value + // breaks every record after it. + assertThatThrownBy(() -> NdjsonFraming.frame(utf8("{\n \"id\": \"a\"\n}"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("breaks every record after it"); + } + + @Test + @DisplayName("an unterminated NDJSON body is detectable") + void unterminatedNdjsonIsDetectable() { + // It leaves the consumer waiting for a line that never arrives. + assertThat(NdjsonFraming.framed(utf8("{\"id\":\"a\"}"))).isFalse(); + assertThat(NdjsonFraming.framed(utf8("{\"id\":\"a\"}\n"))).isTrue(); + assertThatThrownBy(() -> NdjsonFraming.recordCount(utf8("{\"id\":\"a\"}"))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("every JSON-seq record is preceded by RS and followed by LF") + void jsonSeqFramesWithRsAndLf() { + byte[] framed = JsonSequenceFraming.frame(utf8("{\"id\":\"a\"}")); + + assertThat(framed[0]).isEqualTo((byte) 0x1E); + assertThat(framed[framed.length - 1]).isEqualTo((byte) '\n'); + assertThat(JsonSequenceFraming.payload(framed)).isEqualTo("{\"id\":\"a\"}"); + } + + @Test + @DisplayName("a truncated JSON-seq record does not contaminate the next boundary") + void truncatedJsonSeqRecoversAtTheNextSeparator() { + // The whole reason the format exists: the separator comes first, so a parser resynchronises at + // the next record rather than trying to parse the truncation joined to what follows. + ByteArrayOutputStream body = new ByteArrayOutputStream(); + byte[] first = JsonSequenceFraming.frame(utf8("{\"id\":\"a\"}")); + body.write(first, 0, first.length - 4); + body.writeBytes(JsonSequenceFraming.frame(utf8("{\"id\":\"b\"}"))); + byte[] bytes = body.toByteArray(); + + int lastSeparator = -1; + for (int i = 0; i < bytes.length; i++) { + if (bytes[i] == JsonSequenceFraming.RECORD_SEPARATOR) { + lastSeparator = i; + } + } + byte[] recovered = new byte[bytes.length - lastSeparator]; + System.arraycopy(bytes, lastSeparator, recovered, 0, recovered.length); + + assertThat(JsonSequenceFraming.framed(recovered)).isTrue(); + assertThat(JsonSequenceFraming.payload(recovered)).isEqualTo("{\"id\":\"b\"}"); + } + + @Test + @DisplayName("something that is not a record is rejected rather than sliced") + void unframedInputIsRejected() { + assertThat(JsonSequenceFraming.framed(utf8("{\"id\":\"a\"}"))).isFalse(); + assertThatThrownBy(() -> JsonSequenceFraming.payload(utf8("{}"))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("an NDJSON stream carries a completion marker even when empty") + void completionMarkerSeparatesEmptyFromFailed() { + // A zero-byte body and a connection that failed before the first line are the same thing to a + // client. + NdjsonRecord complete = NdjsonRecord.complete(0); + + assertThat(complete.terminal()).isTrue(); + assertThat(NdjsonRecord.item(1, "x").terminal()).isFalse(); + } + + @Test + @DisplayName("an NDJSON item at position zero is refused") + void itemAtPositionZeroIsRefused() { + assertThatThrownBy(() -> NdjsonRecord.item(0, "x")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("count from 1"); + } + + @Test + @DisplayName("the media types are constants, not literals at each writer") + void mediaTypesAreConstants() { + assertThat(StreamMediaType.SSE).isEqualTo("text/event-stream"); + assertThat(StreamMediaType.NDJSON).isEqualTo("application/x-ndjson"); + assertThat(StreamMediaType.JSON_SEQ).isEqualTo("application/json-seq"); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/advanced/stream/replay/GapAndDuplicateGuardTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/advanced/stream/replay/GapAndDuplicateGuardTest.java new file mode 100644 index 00000000..78f5af1d --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/advanced/stream/replay/GapAndDuplicateGuardTest.java @@ -0,0 +1,86 @@ +package dev.caskeleton.adapter.inbound.web.advanced.stream.replay; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.inbound.web.advanced.stream.StreamSequence; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * The seam where replay hands over to live delivery. + * + *

Neither failure is visible in either half on its own: replay ends where the source was when it + * was asked, live starts where it was when the subscription was established, and between those two + * moments the source kept moving. + */ +@Tag("web-advanced") +class GapAndDuplicateGuardTest { + + @Test + @DisplayName("a contiguous handover is clean") + void contiguousHandoverIsClean() { + GapAndDuplicateGuard guard = new GapAndDuplicateGuard(); + + assertThat(guard.observe(new StreamSequence(42))).isEmpty(); + assertThat(guard.observe(new StreamSequence(43))).isEmpty(); + assertThat(guard.observe(new StreamSequence(44))).isEmpty(); + assertThat(guard.lastSeen()).contains(new StreamSequence(44)); + } + + @Test + @DisplayName("live starting behind replay is a duplicate, and the item is dropped") + void liveBehindReplayIsADuplicate() { + // The items are well-formed, so a client that is sent them applies them twice. + GapAndDuplicateGuard guard = new GapAndDuplicateGuard(); + guard.observe(new StreamSequence(42)); + guard.observe(new StreamSequence(43)); + + assertThat(guard.observe(new StreamSequence(42))) + .contains(GapAndDuplicateGuard.SeamFault.DUPLICATE); + assertThat(guard.lastSeen()) + .as("a dropped duplicate must not move the mark backwards") + .contains(new StreamSequence(43)); + } + + @Test + @DisplayName("live starting ahead of replay is a gap") + void liveAheadOfReplayIsAGap() { + // The positions either side of the hole are both valid, so nothing in the data says items are + // missing. + GapAndDuplicateGuard guard = new GapAndDuplicateGuard(); + guard.observe(new StreamSequence(42)); + + assertThat(guard.observe(new StreamSequence(47))).contains(GapAndDuplicateGuard.SeamFault.GAP); + assertThat(guard.lastSeen()).contains(new StreamSequence(47)); + } + + @Test + @DisplayName("an expired cursor is refused, not silently skipped past") + void expiredCursorIsRefused() { + // Resuming from the oldest available position delivers a stream with a hole the client cannot + // see, because the positions are contiguous from where the replay started. + WebStreamResumeCursor cursor = new WebStreamResumeCursor("41"); + + assertThatThrownBy( + () -> { + throw new ReplayCursorExpiredException(cursor); + }) + .isInstanceOf(ReplayCursorExpiredException.class) + .hasMessageContaining("resnapshot is required"); + } + + @Test + @DisplayName("a resume cursor is checked, because it is a client-written header") + void resumeCursorIsChecked() { + // It arrives as a request header and is used to address a durable store. + assertThat(new WebStreamResumeCursor("41").value()).isEqualTo("41"); + assertThat(new WebStreamResumeCursor("eyJvIjoxfQ==").value()).isEqualTo("eyJvIjoxfQ=="); + assertThatThrownBy(() -> new WebStreamResumeCursor("'; DROP TABLE events--")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("checked rather than passed through"); + assertThatThrownBy(() -> new WebStreamResumeCursor("x".repeat(200))) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/advanced/stream/replay/MessagingReplayBridgeTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/advanced/stream/replay/MessagingReplayBridgeTest.java new file mode 100644 index 00000000..a5025518 --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/advanced/stream/replay/MessagingReplayBridgeTest.java @@ -0,0 +1,195 @@ +package dev.caskeleton.adapter.inbound.web.advanced.stream.replay; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.inbound.web.advanced.stream.StreamSequence; +import dev.caskeleton.application.realtime.LiveEventReplayPort; +import dev.caskeleton.application.realtime.ReplayCursorUnavailableException; +import dev.caskeleton.application.realtime.ReplayWindow; +import dev.caskeleton.application.realtime.ReplayedEvent; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * The bridge from {@code Last-Event-ID} to the durable event log. + * + *

What is worth asserting here is the cursor arithmetic and the refusals — the storage belongs + * to the JPA adapter and is tested against a real PostgreSQL there. The two meet at a typed port, + * so the seam that can actually break is this one: a cursor is one opaque string that has to carry + * both a stream and a position, and getting that wrong sends a client to the wrong stream's + * history. + */ +@Tag("web-advanced") +class MessagingReplayBridgeTest { + + private static final Instant NOW = Instant.parse("2026-08-25T10:00:00Z"); + + private static MessagingReplayBridge bridge(LiveEventReplayPort port) { + return new MessagingReplayBridge<>(port, payload -> payload); + } + + @Test + @DisplayName("a cursor carries both the stream and the position") + void cursorCarriesStreamAndPosition() { + // A position alone is ambiguous the moment a deployment serves more than one stream, and the + // client cannot be asked to send the stream separately — Last-Event-ID is one header either + // way. + WebStreamResumeCursor cursor = + MessagingReplayBridge.cursorFor("orders", new StreamSequence(41)); + + assertThat(cursor.value()).isEqualTo("orders:41"); + } + + @Test + @DisplayName("a replay continues after the cursor's position") + void replayContinuesAfterTheCursor() { + StubPort port = new StubPort(1L, 90L); + port.events.add(new ReplayedEvent("orders", 42, "a", NOW)); + port.events.add(new ReplayedEvent("orders", 43, "b", NOW)); + + List> replayed = + bridge(port).replayAfter(new WebStreamResumeCursor("orders:41")); + + assertThat(replayed).extracting(r -> r.sequence().value()).containsExactly(42L, 43L); + assertThat(port.askedAfter).isEqualTo(41L); + assertThat(port.askedStream).isEqualTo("orders"); + } + + @Test + @DisplayName("a stream id containing a colon still parses") + void streamIdMayContainASeparator() { + // Split on the last colon, not the first. A tenant-scoped stream id like "acme:orders" is + // ordinary, and splitting on the first would ask the store for stream "acme" at position + // "orders:41". + StubPort port = new StubPort(1L, 90L); + + bridge(port).replayAfter(new WebStreamResumeCursor("acme:orders:41")); + + assertThat(port.askedStream).isEqualTo("acme:orders"); + assertThat(port.askedAfter).isEqualTo(41L); + } + + @Test + @DisplayName("an expired cursor is refused rather than served from the oldest position") + void expiredCursorIsRefused() { + // Serving would deliver contiguous positions with a hole in the middle, and a client that + // cannot see a gap does not resnapshot — which is the one thing that would fix it. + StubPort port = new StubPort(50L, 90L); + port.expired = true; + + assertThatThrownBy(() -> bridge(port).replayAfter(new WebStreamResumeCursor("orders:41"))) + .isInstanceOf(ReplayCursorExpiredException.class); + } + + @Test + @DisplayName("a cursor this bridge did not mint is refused, not treated as the beginning") + void unreadableCursorIsRefused() { + // A client sending an unreadable cursor believes it has a position; silently replaying + // everything hands it the whole history as though it were the gap it asked for. + StubPort port = new StubPort(1L, 90L); + + assertThatThrownBy(() -> bridge(port).replayAfter(new WebStreamResumeCursor("nonsense"))) + .isInstanceOf(ReplayCursorExpiredException.class); + assertThat(port.askedStream).isNull(); + } + + @Test + @DisplayName("holds() answers from the window rather than by attempting a replay") + void holdsConsultsTheWindow() { + StubPort port = new StubPort(50L, 90L); + + assertThat(bridge(port).holds(new WebStreamResumeCursor("orders:60"))).isTrue(); + assertThat(bridge(port).holds(new WebStreamResumeCursor("orders:10"))).isFalse(); + assertThat(bridge(port).holds(new WebStreamResumeCursor("nonsense"))).isFalse(); + } + + @Test + @DisplayName("a client already at the newest position can still be served") + void currentClientIsNotExpired() { + // Up to date is not the same as too old, and answering "cannot resume" would make an idle + // client resnapshot every time it reconnected. + StubPort port = new StubPort(50L, 90L); + + assertThat(bridge(port).holds(new WebStreamResumeCursor("orders:90"))).isTrue(); + assertThat(bridge(port).replayAfter(new WebStreamResumeCursor("orders:90"))).isEmpty(); + } + + @Test + @DisplayName("the live boundary is the last replayed position") + void liveBoundaryIsTheLastReplayedPosition() { + StubPort port = new StubPort(1L, 90L); + port.events.add(new ReplayedEvent("orders", 42, "a", NOW)); + port.events.add(new ReplayedEvent("orders", 43, "b", NOW)); + MessagingReplayBridge bridge = bridge(port); + + List> replayed = + bridge.replayAfter(new WebStreamResumeCursor("orders:41")); + + assertThat(bridge.liveBoundary(replayed).value()).isEqualTo(43L); + } + + @Test + @DisplayName("an empty replay claims no boundary it does not know") + void emptyReplayClaimsNoBoundary() { + // Returning the client's own position would assert a boundary this bridge did not observe; the + // gap guard is what notices if live does not start where the client left off. + assertThat(bridge(new StubPort(1L, 90L)).liveBoundary(List.of()).value()).isEqualTo(1L); + } + + @Test + @DisplayName("a store that cannot be read reports holding nothing") + void unreadableStoreHoldsNothing() { + assertThat(bridge(new FailingPort()).holds(new WebStreamResumeCursor("orders:41"))).isFalse(); + } + + /** A replay port over a fixed window and a fixed event list. */ + private static final class StubPort implements LiveEventReplayPort { + + private final long earliest; + private final long latest; + private final List events = new ArrayList<>(); + private boolean expired; + private String askedStream; + private long askedAfter = -1; + + StubPort(long earliest, long latest) { + this.earliest = earliest; + this.latest = latest; + } + + @Override + public ReplayWindow window(String streamId) { + return new ReplayWindow(streamId, Optional.of(earliest), Optional.of(latest)); + } + + @Override + public List replayAfter(String streamId, long afterPosition, int limit) { + askedStream = streamId; + askedAfter = afterPosition; + if (expired) { + throw new ReplayCursorUnavailableException(streamId, afterPosition); + } + return List.copyOf(events); + } + } + + /** A replay port that is down. */ + private static final class FailingPort implements LiveEventReplayPort { + + @Override + public ReplayWindow window(String streamId) { + throw new IllegalStateException("unreachable"); + } + + @Override + public List replayAfter(String streamId, long afterPosition, int limit) { + throw new IllegalStateException("unreachable"); + } + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/advanced/virtualthread/VirtualThreadAdmissionTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/advanced/virtualthread/VirtualThreadAdmissionTest.java new file mode 100644 index 00000000..2254a7c5 --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/advanced/virtualthread/VirtualThreadAdmissionTest.java @@ -0,0 +1,137 @@ +package dev.caskeleton.adapter.inbound.web.advanced.virtualthread; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * Virtual threads remove the cost of waiting. They do not remove the reason the waiting was + * bounded. + * + *

The failure this guards is not a slow system. It is a system that accepts ten thousand + * concurrent requests, queues all of them on a twenty-connection pool, and times out every one — + * having done no useful work. + */ +@Tag("web-advanced") +class VirtualThreadAdmissionTest { + + @Test + @DisplayName("more virtual threads than permits still means only permits-many run") + void virtualThreadsDoNotBypassTheLimit() throws Exception { + // The assertion that matters, and the one invisible from throughput: peak concurrency, not + // thread count. + int limit = 8; + int arrivals = 96; + VirtualThreadAdmissionGuard guard = + new VirtualThreadAdmissionGuard(limit, Duration.ofSeconds(5)); + AtomicInteger threadsCreated = new AtomicInteger(); + CountDownLatch started = new CountDownLatch(arrivals); + + try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) { + List> futures = new ArrayList<>(); + for (int i = 0; i < arrivals; i++) { + futures.add( + executor.submit( + () -> { + threadsCreated.incrementAndGet(); + started.countDown(); + return guard.run( + () -> { + Thread.sleep(5); + return null; + }); + })); + } + assertThat(started.await(30, TimeUnit.SECONDS)).isTrue(); + for (Future future : futures) { + future.get(30, TimeUnit.SECONDS); + } + } + + assertThat(guard.peakActive()) + .as("more than the limit ran at once, so the limit is not being applied") + .isLessThanOrEqualTo(limit); + assertThat(threadsCreated.get()) + .as( + "if only limit-many threads ever existed, the test proved nothing about virtual threads") + .isGreaterThan(limit); + } + + @Test + @DisplayName("an arrival that cannot get a permit in time is refused, not queued forever") + void arrivalsAreRefusedRatherThanQueuedForever() throws Exception { + // A request refused in a millisecond is strictly better for the client than the same request + // accepted and timed out thirty seconds later behind a full pool. + VirtualThreadAdmissionGuard guard = new VirtualThreadAdmissionGuard(1, Duration.ofMillis(50)); + CountDownLatch hold = new CountDownLatch(1); + CountDownLatch holding = new CountDownLatch(1); + + try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) { + Future holder = + executor.submit( + () -> + guard.run( + () -> { + holding.countDown(); + hold.await(); + return null; + })); + assertThat(holding.await(10, TimeUnit.SECONDS)).isTrue(); + + assertThatThrownBy(() -> guard.run(() -> null)).isInstanceOf(AdmissionRefusedException.class); + assertThat(guard.rejectedCount()).isOne(); + hold.countDown(); + holder.get(10, TimeUnit.SECONDS); + } + } + + @Test + @DisplayName("enabling virtual threads without an admission limit is refused") + void enablingWithoutALimitIsRefused() { + assertThatThrownBy(() -> new VirtualThreadProfile(true, 0, 20, 20)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("times out work that would have succeeded had it been refused"); + } + + @Test + @DisplayName("the downstream budgets are stated and unchanged") + void downstreamBudgetsAreStated() { + // The whole point is that they did not grow. + VirtualThreadProfile profile = new VirtualThreadProfile(true, 100, 20, 20); + + assertThat(profile.databasePoolSize()).isEqualTo(20); + assertThat(profile.outboundBulkhead()).isEqualTo(20); + assertThat(profile.admissionFitsDownstreamBudgets()) + .as("100 admitted against 40 downstream slots is a choice, and it is reported") + .isFalse(); + assertThat(new VirtualThreadProfile(true, 30, 20, 20).admissionFitsDownstreamBudgets()) + .isTrue(); + } + + @Test + @DisplayName("what has to be watched once this is on is written down") + void requiredObservationsAreRecorded() { + assertThat(new VirtualThreadProfile(true, 30, 20, 20).requiredObservations()) + .anyMatch(line -> line.contains("VirtualThreadPinned")) + .anyMatch(line -> line.contains("admission rejections")); + } + + @Test + @DisplayName("a profile that is off states nothing and refuses nothing") + void disabledProfileIsInert() { + assertThat(VirtualThreadProfile.disabled().enabled()).isFalse(); + assertThat(VirtualThreadProfile.disabled().admissionFitsDownstreamBudgets()).isTrue(); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/advanced/webflux/WebFluxStreamingTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/advanced/webflux/WebFluxStreamingTest.java new file mode 100644 index 00000000..f200ed3e --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/advanced/webflux/WebFluxStreamingTest.java @@ -0,0 +1,309 @@ +package dev.caskeleton.adapter.inbound.web.advanced.webflux; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.inbound.web.advanced.blockingbridge.BlockingBridgeBudget; +import dev.caskeleton.adapter.inbound.web.advanced.blockingbridge.BlockingBridgeProfile; +import dev.caskeleton.adapter.inbound.web.advanced.blockingbridge.BlockingBridgeRejectedException; +import dev.caskeleton.adapter.inbound.web.advanced.stream.StreamId; +import dev.caskeleton.adapter.inbound.web.advanced.stream.StreamSequence; +import dev.caskeleton.adapter.inbound.web.advanced.stream.WebStreamEnvelope; +import dev.caskeleton.adapter.inbound.web.advanced.stream.WebStreamEvidence; +import dev.caskeleton.adapter.inbound.web.advanced.stream.WebStreamPolicy; +import dev.caskeleton.adapter.inbound.web.advanced.stream.WebStreamRegistry; +import dev.caskeleton.adapter.inbound.web.advanced.stream.WebStreamTermination; +import java.time.Duration; +import java.time.Instant; +import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.http.codec.ServerSentEvent; +import reactor.core.Disposable; +import reactor.core.publisher.Flux; +import reactor.core.publisher.FluxSink; +import reactor.core.scheduler.Schedulers; +import reactor.test.StepVerifier; + +/** + * The reactive side, where a disconnect arrives as a cancel signal rather than as a failed write. + * + *

Which makes the detection easy and the propagation the thing to get wrong. + */ +@Tag("web-advanced") +class WebFluxStreamingTest { + + private static final Instant NOW = Instant.parse("2026-08-25T10:00:00Z"); + private static final StreamId STREAM = new StreamId("orders-feed"); + + private static WebStreamPolicy policy(int buffer) { + return new WebStreamPolicy( + Duration.ofSeconds(15), Duration.ofSeconds(60), Duration.ofMinutes(30), buffer, 262_144); + } + + private static WebStreamEnvelope item(long sequence) { + return new WebStreamEnvelope.Item<>(STREAM, new StreamSequence(sequence), "payload"); + } + + /** + * A source that pushes regardless of demand, which is the only kind that can overflow a buffer. + * + *

Two sources that look like they would and do not. {@code Flux.range} honours demand, so a + * slow consumer merely slows it down. {@code Flux.interval} honours it too — it stalls when + * nothing has been requested — so a chain built on either simply goes quiet, and a test written + * against one passes with the bound removed. + * + *

What a real event feed does is push: something happened, so an item exists, whether or not + * anybody asked for it. {@code OverflowStrategy.IGNORE} is that, and it is what makes the bound + * the thing that decides. + */ + private static Flux> unpaced() { + return Flux.create( + sink -> { + for (long sequence = 1; sequence <= 1_000; sequence++) { + sink.next(item(sequence)); + } + sink.complete(); + }, + FluxSink.OverflowStrategy.IGNORE); + } + + @Test + @DisplayName("a slow consumer is closed rather than buffered without limit") + void slowConsumerIsClosed() { + // Backpressure protects the pipeline from a fast producer. It does nothing about a consumer + // that reads slowly for an hour, and whatever the pipeline holds for it is this process's heap. + Flux>> stream = + new WebFluxSseAdapter(policy(8)).adapt(unpaced()); + + StepVerifier.create(stream, 0) + .thenRequest(8) + .expectNextCount(8) + .expectError(SlowConsumerClosedException.class) + .verify(Duration.ofSeconds(10)); + } + + @Test + @DisplayName("overflow closes the stream instead of dropping an item") + void overflowDoesNotDropSilently() { + // DROP_OLDEST is the tempting setting and it produces a gap the client cannot see, because the + // positions either side of it are both valid. + Flux>> stream = + new WebFluxSseAdapter(policy(4)).adapt(unpaced()); + + StepVerifier.create(stream, 0) + .thenRequest(4) + .expectNextCount(4) + .expectErrorSatisfies( + failure -> + assertThat(failure) + .isInstanceOf(SlowConsumerClosedException.class) + .hasMessageContaining("rather than holding its backlog")) + .verify(Duration.ofSeconds(10)); + } + + @Test + @DisplayName("a source that finishes ends the stream instead of heartbeating to its max age") + void finishedSourceEndsTheStream() { + // The heartbeat is infinite, so merging it without a terminal condition holds the connection + // for the full maximum age after the last item — thirty minutes of keepalives on a stream that + // finished. + StepVerifier.create( + new WebFluxSseAdapter(policy(16)) + .adapt( + Flux.just( + item(1), new WebStreamEnvelope.Complete<>(STREAM, new StreamSequence(1))))) + .expectNextCount(2) + .verifyComplete(); + } + + @Test + @DisplayName("a source that forgets its terminal envelope has one supplied") + void missingTerminalIsSupplied() { + // A client that receives neither terminal envelope has been cut off, and that has to stay + // distinguishable from a clean end. + StepVerifier.create(new WebFluxSseAdapter(policy(16)).adapt(Flux.just(item(1), item(2)))) + .expectNextCount(2) + .assertNext( + event -> { + assertThat(event.event()).isEqualTo("complete"); + assertThat(event.data()).isInstanceOf(WebStreamEnvelope.Complete.class); + }) + .verifyComplete(); + } + + @Test + @DisplayName("only items carry an SSE id, so a resume never continues a finished stream") + void onlyItemsCarryAnId() { + Flux>> stream = + new WebFluxSseAdapter(policy(16)) + .adapt( + Flux.just( + item(41), new WebStreamEnvelope.Complete<>(STREAM, new StreamSequence(41)))); + + StepVerifier.create(stream) + .assertNext( + event -> { + assertThat(event.event()).isEqualTo("item"); + assertThat(event.id()).isEqualTo("41"); + }) + .assertNext( + event -> { + assertThat(event.event()).isEqualTo("complete"); + assertThat(event.id()).isNull(); + }) + .verifyComplete(); + } + + @Test + @DisplayName("a cancel is recorded as the client leaving, not as a fault") + void cancelIsRecordedAsADisconnect() { + WebStreamEvidence evidence = new WebStreamEvidence(STREAM, NOW); + Flux observed = + WebFluxDisconnectDetector.observe(Flux.interval(Duration.ofMillis(5)), evidence, () -> NOW); + + StepVerifier.create(observed).expectNextCount(2).thenCancel().verify(Duration.ofSeconds(10)); + + assertThat(evidence.termination()).contains(WebStreamTermination.CLIENT_DISCONNECTED); + } + + @Test + @DisplayName("a completion is recorded as a completion") + void completionIsRecorded() { + WebStreamEvidence evidence = new WebStreamEvidence(STREAM, NOW); + + StepVerifier.create(WebFluxDisconnectDetector.observe(Flux.just(1, 2), evidence, () -> NOW)) + .expectNextCount(2) + .verifyComplete(); + + assertThat(evidence.termination()).contains(WebStreamTermination.NORMAL_COMPLETE); + } + + @Test + @DisplayName("a slow-consumer close is not counted as a server fault") + void slowConsumerIsNotAServerFault() { + // It is the shedding bound working. Counting it as a fault makes the bound invisible in the + // metric that would show it firing. + assertThat(WebFluxDisconnectDetector.classify(new SlowConsumerClosedException(8))) + .isEqualTo(WebStreamTermination.CLIENT_DISCONNECTED); + assertThat(WebFluxDisconnectDetector.classify(new IllegalStateException("dependency down"))) + .isEqualTo(WebStreamTermination.TERMINAL_ERROR_RECORD); + } + + @Test + @DisplayName("a node at its ceiling refuses a reactive stream too") + void reactiveAdmissionIsBounded() { + WebFluxStreamAdmission admission = new WebFluxStreamAdmission(new WebStreamRegistry(1)); + + assertThat(admission.admit(new StreamId("first"), NOW, null)).isPresent(); + assertThat(admission.admit(new StreamId("second"), NOW, null)).isEmpty(); + } + + @Test + @DisplayName("releasing a slot twice does not return it twice") + void releaseIsIdempotent() { + // A cancel racing a complete is normal at the moment a client disconnects from a finishing + // stream. Releasing twice drifts the count below the truth until the node admits more streams + // than it has capacity for. + WebStreamRegistry registry = new WebStreamRegistry(1); + WebFluxStreamAdmission admission = new WebFluxStreamAdmission(registry); + WebFluxStreamAdmission.Admitted admitted = + admission.admit(new StreamId("feed"), NOW, null).orElseThrow(); + + admitted.release(); + admitted.release(); + + assertThat(registry.activeStreams()).isZero(); + assertThat(admission.admit(new StreamId("next"), NOW, null)).isPresent(); + assertThat(admission.admit(new StreamId("third"), NOW, null)).isEmpty(); + } + + @Test + @DisplayName("forcing a reactive stream closed disposes its subscription") + void forceCloseDisposesUpstream() { + // The source must stop producing for a stream nobody is reading. + AtomicBoolean disposed = new AtomicBoolean(); + Disposable subscription = + new Disposable() { + @Override + public void dispose() { + disposed.set(true); + } + + @Override + public boolean isDisposed() { + return disposed.get(); + } + }; + WebFluxStreamAdmission admission = new WebFluxStreamAdmission(new WebStreamRegistry(2)); + + admission + .admit(new StreamId("feed"), NOW, subscription) + .orElseThrow() + .session() + .forceClose(WebStreamTermination.ABRUPT_CLOSE); + + assertThat(disposed).isTrue(); + } + + @Test + @DisplayName("blocking work runs off the event loop and stays inside its bound") + void blockingWorkIsOffLoopAndBounded() { + BlockingBridgeBudget budget = + new BlockingBridgeBudget( + new BlockingBridgeProfile(Set.of("jpa.read"), 4, Duration.ofSeconds(5))); + ControlledBlockingBridge bridge = + new ControlledBlockingBridge(Schedulers.boundedElastic(), budget); + + Flux names = + Flux.range(0, 32) + .flatMap( + index -> bridge.execute("jpa.read", () -> Thread.currentThread().getName()), 32); + + StepVerifier.create(names.collectList()) + .assertNext( + all -> { + assertThat(all).hasSize(32); + assertThat(all).noneMatch(name -> name.startsWith("reactor-http-")); + }) + .expectComplete() + .verify(Duration.ofSeconds(30)); + assertThat(budget.peakConcurrency()).isLessThanOrEqualTo(4); + assertThat(budget.inFlight()).isZero(); + } + + @Test + @DisplayName("an unregistered operation cannot reach the offload pool") + void unregisteredOperationIsRefused() { + ControlledBlockingBridge bridge = + new ControlledBlockingBridge( + Schedulers.boundedElastic(), + new BlockingBridgeBudget( + new BlockingBridgeProfile(Set.of("jpa.read"), 4, Duration.ofSeconds(1)))); + + StepVerifier.create(bridge.execute("legacy.soap.call", () -> "x")) + .expectError(BlockingBridgeRejectedException.class) + .verify(Duration.ofSeconds(10)); + } + + @Test + @DisplayName("a Mono that is assembled and never subscribed takes no permit") + void assemblyTakesNoPermit() { + // Acquiring at assembly rather than at subscription holds a slot for ever on a pipeline that + // was built and discarded, which is the classic Reactor side-effect bug. + BlockingBridgeBudget budget = + new BlockingBridgeBudget( + new BlockingBridgeProfile(Set.of("jpa.read"), 1, Duration.ofMillis(50))); + ControlledBlockingBridge bridge = + new ControlledBlockingBridge(Schedulers.boundedElastic(), budget); + + bridge.execute("jpa.read", () -> "never subscribed"); + bridge.execute("jpa.read", () -> "also never subscribed"); + + assertThat(budget.inFlight()).isZero(); + StepVerifier.create(bridge.execute("jpa.read", () -> "subscribed")) + .expectNext("subscribed") + .verifyComplete(); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/budget/WebRequestBudgetTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/budget/WebRequestBudgetTest.java new file mode 100644 index 00000000..25f51374 --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/budget/WebRequestBudgetTest.java @@ -0,0 +1,110 @@ +package dev.caskeleton.adapter.inbound.web.budget; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** Every bound is a bound, and an override may only tighten one. */ +class WebRequestBudgetTest { + + @Test + @DisplayName("an unlimited body is refused") + void anUnlimitedBodyIsRefused() { + assertThatThrownBy(() -> budget(Long.MAX_VALUE, Duration.ofSeconds(30))) + .as("a body limit of Long.MAX_VALUE is the absence of a limit spelled as a number") + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("body limit"); + assertThatThrownBy(() -> budget(0, Duration.ofSeconds(30))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("a profile with no execution limit is refused") + void aProfileWithNoExecutionLimitIsRefused() { + assertThatThrownBy(() -> budget(1024, null)) + .as("the request that never finishes holds a worker, and enough of them hold every worker") + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("execution time limit"); + assertThatThrownBy(() -> budget(1024, Duration.ZERO)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> budget(1024, Duration.ofMinutes(5))) + .as("beyond the platform maximum a client has already given up") + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("platform maximum"); + } + + @Test + @DisplayName("the standard profile is inside every platform maximum") + void theStandardProfileIsInsideEveryPlatformMaximum() { + assertThat(WebRequestBudget.standard().withinPlatformMaximum()).isTrue(); + assertThatCode(WebRequestBudget::standard).doesNotThrowAnyException(); + } + + @Test + @DisplayName("an override that widens any axis is refused") + void anOverrideThatWidensAnyAxisIsRefused() { + WebBudgetCatalog catalog = new WebBudgetCatalog(); + WebBudgetProfileName base = WebBudgetProfileName.standard(); + catalog.register(base, WebRequestBudget.standard()); + + WebRequestBudget tighter = + new WebRequestBudget( + 1024, 4096, 32, 64L * 1024, 16, 500, 8, Duration.ofSeconds(5), 1024 * 1024); + assertThatCode( + () -> catalog.registerOverride(new WebBudgetProfileName("orders.bulk"), base, tighter)) + .doesNotThrowAnyException(); + + WebRequestBudget widerArrays = + new WebRequestBudget( + 1024, 4096, 32, 64L * 1024, 16, 5_000, 8, Duration.ofSeconds(5), 1024 * 1024); + assertThatThrownBy( + () -> + catalog.registerOverride( + new WebBudgetProfileName("orders.wide"), base, widerArrays)) + .as( + "an override that shrinks the body while doubling the array count has widened the budget") + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("wider than its base profile"); + } + + @Test + @DisplayName("a duplicate or unknown budget profile is refused") + void aDuplicateOrUnknownBudgetProfileIsRefused() { + WebBudgetCatalog catalog = new WebBudgetCatalog(); + catalog.register(WebBudgetProfileName.standard(), WebRequestBudget.standard()); + + assertThatThrownBy( + () -> catalog.register(WebBudgetProfileName.standard(), WebRequestBudget.standard())) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("duplicate budget profile"); + assertThatThrownBy(() -> catalog.require(new WebBudgetProfileName("absent"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("unknown budget profile"); + assertThat(catalog.registered()).hasSize(1); + } + + @Test + @DisplayName("every non-time axis is positive and bounded") + void everyNonTimeAxisIsPositiveAndBounded() { + assertThatThrownBy( + () -> + new WebRequestBudget(0, 8192, 64, 1024, 32, 1000, 16, Duration.ofSeconds(5), 1024)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy( + () -> + new WebRequestBudget( + 2048, 8192, 64, 1024, 1_000, 1000, 16, Duration.ofSeconds(5), 1024)) + .as("a JSON depth of a thousand is a stack overflow with a limit attached") + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("platform maximum"); + } + + private static WebRequestBudget budget(long maxBodyBytes, Duration maxExecutionTime) { + return new WebRequestBudget( + 8192, 16384, 100, maxBodyBytes, 64, 1000, 20, maxExecutionTime, 4_194_304); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/cache/WebCachePolicyTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/cache/WebCachePolicyTest.java new file mode 100644 index 00000000..26adae97 --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/cache/WebCachePolicyTest.java @@ -0,0 +1,143 @@ +package dev.caskeleton.adapter.inbound.web.cache; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; +import java.util.Optional; +import java.util.Set; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Each case is a way a caching decision goes wrong without anybody noticing. + * + *

Cache bugs are invisible in a test that calls the endpoint once. They appear as one caller + * seeing another's data, or as a cache that costs storage and never hits, and both look fine from + * the server's own logs. + */ +class WebCachePolicyTest { + + @Test + @DisplayName("a sensitive response is private and never stored") + void sensitiveResponseIsPrivateNoStore() { + assertThat(WebCachePolicyCatalog.standard().require("sensitive").headers().cacheControl()) + .isEqualTo("private, no-store"); + } + + @Test + @DisplayName("no-store, not no-cache, is what keeps a response off disk") + void sensitiveUsesNoStoreRatherThanNoCache() { + // The confusion this asserts against is the common one: no-cache permits storing and requires + // revalidation, so the document still lands in the cache and on the disk behind it. + String cacheControl = + WebCachePolicyCatalog.standard().require("sensitive").headers().cacheControl(); + + assertThat(cacheControl).contains("no-store"); + assertThat(cacheControl).doesNotContain("no-cache"); + } + + @Test + @DisplayName("only a policy that says so may hold an authorized response in a shared cache") + void authorizedResponsesNeedAnExplicitSharedProfile() { + assertThat(WebCachePolicyCatalog.standard().require("sensitive").safeForAuthorizedResponse()) + .isTrue(); + assertThat( + WebCachePolicyCatalog.standard().require("browser-private").safeForAuthorizedResponse()) + .isTrue(); + // Storable by a proxy, which will serve it to whoever asks for the URL next. + assertThat(WebCachePolicyCatalog.standard().require("revalidated").safeForAuthorizedResponse()) + .isFalse(); + } + + @Test + @DisplayName("Vary refuses headers that are unique per client") + void varyRefusesPerClientHeaders() { + // Keying on any of these gives every caller a private entry: all the storage cost of a cache, + // none of the sharing. + assertThatThrownBy(() -> WebVaryPolicy.varyingOn(Set.of("User-Agent"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("unique per client"); + assertThatThrownBy(() -> WebVaryPolicy.varyingOn(Set.of("Cookie"))) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> WebVaryPolicy.varyingOn(Set.of("Authorization"))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("Vary is case-insensitive about header names") + void varyNormalisesCase() { + assertThat(WebVaryPolicy.varyingOn(Set.of("Accept-Language")).headerNames()) + .containsExactly("accept-language"); + assertThat(WebVaryPolicy.forbidden("USER-AGENT")).isTrue(); + } + + @Test + @DisplayName("a policy that varies on nothing emits no Vary at all") + void noVaryMeansNoHeader() { + // An empty `Vary:` is not the same as no Vary, and some intermediaries treat it as `Vary: *`. + assertThat(WebCachePolicyCatalog.standard().require("sensitive").headers().vary()).isEmpty(); + } + + @Test + @DisplayName("a shared profile varies on what actually changes the representation") + void sharedProfileVariesOnNegotiatedHeaders() { + assertThat(WebCachePolicyCatalog.standard().require("revalidated").headers().vary()) + .contains("accept, accept-encoding, accept-language"); + } + + @Test + @DisplayName("no-store with a freshness lifetime is refused") + void noStoreCannotHaveFreshness() { + assertThatThrownBy( + () -> + new WebCachePolicy( + "broken", + "private, no-store", + WebVaryPolicy.none(), + false, + Optional.of(Duration.ofMinutes(5)))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("contradict"); + } + + @Test + @DisplayName("public directives and the shared-cache flag must agree") + void publicAndSharedFlagMustAgree() { + assertThatThrownBy( + () -> + new WebCachePolicy( + "broken", "public, max-age=60", WebVaryPolicy.none(), false, Optional.empty())) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy( + () -> + new WebCachePolicy( + "broken", "private, max-age=60", WebVaryPolicy.none(), true, Optional.empty())) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("an unnamed profile is refused rather than answered with a default") + void unknownProfileIsRefused() { + // Returning a default would let a typo silently pick the wrong caching for an endpoint. + assertThatThrownBy(() -> WebCachePolicyCatalog.standard().require("no-such-profile")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("published set"); + } + + @Test + @DisplayName("immutable is only claimed where the URL cannot change") + void immutableIsScopedToAddressedContent() { + WebCachePolicy asset = WebCachePolicyCatalog.standard().require("immutable-asset"); + + assertThat(asset.cacheControl()).contains("immutable"); + assertThat(asset.freshness()).contains(Duration.ofDays(365)); + // Nothing else claims it: a year of unrevalidatable staleness on a changing resource has no + // remedy short of changing the URL. + assertThat( + WebCachePolicyCatalog.standard().registered().values().stream() + .filter(policy -> policy.cacheControl().contains("immutable")) + .count()) + .isOne(); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/conditional/ConditionalReadEvaluatorTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/conditional/ConditionalReadEvaluatorTest.java new file mode 100644 index 00000000..3063ff9b --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/conditional/ConditionalReadEvaluatorTest.java @@ -0,0 +1,88 @@ +package dev.caskeleton.adapter.inbound.web.conditional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** A read asks "is what I hold still good enough to render", which weak comparison answers. */ +class ConditionalReadEvaluatorTest { + + private final ConditionalReadEvaluator evaluator = new ConditionalReadEvaluator(); + + @Test + @DisplayName("a matching validator answers 304") + void aMatchingValidatorAnswers304() { + assertThat(evaluator.evaluate("\"v1\"", EntityTag.strong("v1"))) + .isEqualTo(ConditionalReadDecision.NOT_MODIFIED); + assertThat(evaluator.notModified("\"v0\", \"v1\"", EntityTag.strong("v1"))).isTrue(); + } + + @Test + @DisplayName("a read matches weakly, so a cosmetic difference still hits the cache") + void aReadMatchesWeakly() { + assertThat(evaluator.notModified("W/\"v1\"", EntityTag.strong("v1"))) + .as("strong comparison here sends a full body for every whitespace difference") + .isTrue(); + assertThat(evaluator.notModified("\"v1\"", EntityTag.weak("v1"))).isTrue(); + } + + @Test + @DisplayName("no validator, no header, or no match serves the representation") + void noMatchServesTheRepresentation() { + assertThat(evaluator.evaluate(null, EntityTag.strong("v1"))) + .isEqualTo(ConditionalReadDecision.SERVE_REPRESENTATION); + assertThat(evaluator.evaluate("\"v2\"", EntityTag.strong("v1"))) + .isEqualTo(ConditionalReadDecision.SERVE_REPRESENTATION); + assertThat(evaluator.evaluate("\"v1\"", null)) + .isEqualTo(ConditionalReadDecision.SERVE_REPRESENTATION); + } + + @Test + @DisplayName("the wildcard matches whenever a representation exists") + void theWildcardMatchesWheneverARepresentationExists() { + assertThat(evaluator.notModified("*", EntityTag.strong("v1"))).isTrue(); + assertThat(evaluator.notModified("*", null)).isFalse(); + } + + @Test + @DisplayName("a malformed validator is not silently treated as no validator") + void aMalformedValidatorIsDropped() { + // A malformed entry is dropped from the list rather than turning the whole request + // unconditional, so a client sending one bad tag among good ones still gets its 304. + assertThat(EntityTagCodec.parseList("garbage, \"v1\"")).hasSize(1); + assertThat(EntityTagCodec.parseSingle("v1")) + .as("an unquoted value is not a validator") + .isEmpty(); + assertThat(EntityTagCodec.parseSingle(null)).isEmpty(); + } + + @Test + @DisplayName("the wire form round-trips including weakness") + void theWireFormRoundTrips() { + assertThat(EntityTag.strong("v1").toHeaderValue()).isEqualTo("\"v1\""); + assertThat(EntityTag.weak("v1").toHeaderValue()).isEqualTo("W/\"v1\""); + assertThat(EntityTagCodec.parseSingle("W/\"v1\"")).contains(EntityTag.weak("v1")); + } + + @Test + @DisplayName("a tag value keeps its quotes out and its control characters out") + void aTagValueIsClean() { + assertThatThrownBy(() -> EntityTag.strong("has\"quote")) + .as("keeping the quotes in the value is how a comparison matches the wrong thing") + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> EntityTag.strong("")).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> EntityTag.strong("a".repeat(257))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("an application version is not assumed to be a storage ETag") + void anApplicationVersionIsNotAssumedToBeAStorageEtag() { + assertThat(evaluator.toEntityTag("7", true).weak()).isFalse(); + assertThat(evaluator.toEntityTag("7", false).weak()) + .as("a revision, a storage ETag and a provider ETag validate different things") + .isTrue(); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/conditional/MutationPreconditionEvaluatorTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/conditional/MutationPreconditionEvaluatorTest.java new file mode 100644 index 00000000..3449f0cf --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/conditional/MutationPreconditionEvaluatorTest.java @@ -0,0 +1,99 @@ +package dev.caskeleton.adapter.inbound.web.conditional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.inbound.web.operation.PreconditionPolicy; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** A write asks "is this still the thing I read", which only strong comparison answers. */ +class MutationPreconditionEvaluatorTest { + + private final MutationPreconditionEvaluator evaluator = new MutationPreconditionEvaluator(); + + @Test + @DisplayName("a matching strong If-Match proceeds and a mismatch is 412") + void aMatchingStrongIfMatchProceeds() { + assertThat(evaluate("\"v1\"", null, EntityTag.strong("v1"))) + .isEqualTo(PreconditionDecision.PROCEED); + assertThat(evaluate("\"v0\"", null, EntityTag.strong("v1"))) + .isEqualTo(PreconditionDecision.PRECONDITION_FAILED); + } + + @Test + @DisplayName("a weak validator never satisfies If-Match") + void aWeakValidatorNeverSatisfiesIfMatch() { + assertThat(evaluate("W/\"v1\"", null, EntityTag.strong("v1"))) + .as( + "two writers whose representations differ cosmetically would both pass, and the second" + + " would overwrite the first") + .isEqualTo(PreconditionDecision.PRECONDITION_FAILED); + assertThat(evaluate("\"v1\"", null, EntityTag.weak("v1"))) + .isEqualTo(PreconditionDecision.PRECONDITION_FAILED); + } + + @Test + @DisplayName("create-only If-None-Match star fails against an existing resource") + void createOnlyFailsAgainstAnExistingResource() { + assertThat(evaluate(null, "*", EntityTag.strong("v1"))) + .isEqualTo(PreconditionDecision.PRECONDITION_FAILED); + assertThat(evaluate(null, "*", null)).isEqualTo(PreconditionDecision.PROCEED); + } + + @Test + @DisplayName("update-only If-Match star fails against a missing resource") + void updateOnlyFailsAgainstAMissingResource() { + assertThat(evaluate("*", null, null)).isEqualTo(PreconditionDecision.PRECONDITION_FAILED); + assertThat(evaluate("*", null, EntityTag.strong("v1"))).isEqualTo(PreconditionDecision.PROCEED); + } + + @Test + @DisplayName("a required precondition that was not supplied is 428, not 412") + void aRequiredPreconditionThatWasNotSuppliedIs428() { + assertThat( + evaluator.evaluate( + PreconditionPolicy.REQUIRED, HttpPrecondition.none(), EntityTag.strong("v1"))) + .as("412 would tell a client to retry something that cannot succeed unchanged") + .isEqualTo(PreconditionDecision.PRECONDITION_REQUIRED); + assertThat( + evaluator.evaluate( + PreconditionPolicy.OPTIONAL, HttpPrecondition.none(), EntityTag.strong("v1"))) + .isEqualTo(PreconditionDecision.PROCEED); + } + + @Test + @DisplayName("the throwing form carries which outcome occurred") + void theThrowingFormCarriesTheOutcome() { + assertThatCode( + () -> + evaluator.requireSatisfied( + PreconditionPolicy.OPTIONAL, + HttpPrecondition.of("\"v1\"", null), + EntityTag.strong("v1"))) + .doesNotThrowAnyException(); + + assertThatThrownBy( + () -> + evaluator.requireSatisfied( + PreconditionPolicy.REQUIRED, HttpPrecondition.none(), EntityTag.strong("v1"))) + .isInstanceOf(WebPreconditionFailedException.class) + .extracting(failure -> ((WebPreconditionFailedException) failure).decision()) + .isEqualTo(PreconditionDecision.PRECONDITION_REQUIRED); + } + + @Test + @DisplayName("no precondition supplied is a distinguishable state") + void noPreconditionSuppliedIsDistinguishable() { + assertThat(HttpPrecondition.none().present()).isFalse(); + assertThat(HttpPrecondition.of("\"v1\"", null).present()).isTrue(); + assertThat(HttpPrecondition.of("*", null).ifMatchWildcard()).isTrue(); + assertThat(HttpPrecondition.of(null, "*").ifNoneMatchWildcard()).isTrue(); + } + + private PreconditionDecision evaluate(String ifMatch, String ifNoneMatch, EntityTag current) { + return evaluator.evaluate( + PreconditionPolicy.OPTIONAL, HttpPrecondition.of(ifMatch, ifNoneMatch), current); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/contract/WebWireTypeManifestTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/contract/WebWireTypeManifestTest.java new file mode 100644 index 00000000..649cc3fe --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/contract/WebWireTypeManifestTest.java @@ -0,0 +1,91 @@ +package dev.caskeleton.adapter.inbound.web.contract; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.EnumMap; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** The wire contract is fixed, complete, and the committed document says the same thing. */ +class WebWireTypeManifestTest { + + @Test + @DisplayName("an instant is a UTC RFC 3339 string, not an epoch decimal") + void anInstantIsAUtcRfc3339String() { + assertThat(WebWireTypeManifest.standard().require(WebWireType.INSTANT).wireFormat()) + .isEqualTo("RFC3339_UTC"); + } + + @Test + @DisplayName("a long is a string, because a JSON number is rounded past 2^53") + void aLongIsAString() { + assertThat(WebWireTypeManifest.standard().require(WebWireType.LONG).wireFormat()) + .as("a JavaScript client silently rounds it, so the id it echoes back is not the one sent") + .isEqualTo("INTEGER_STRING"); + assertThat(WebWireTypeManifest.standard().require(WebWireType.BIG_DECIMAL).wireFormat()) + .isEqualTo("DECIMAL_STRING"); + } + + @Test + @DisplayName("a partial manifest is refused") + void aPartialManifestIsRefused() { + Map partial = new EnumMap<>(WebWireType.class); + partial.put(WebWireType.INSTANT, new WireTypeRule("RFC3339_UTC", false)); + + assertThatThrownBy(() -> new WebWireTypeManifest(partial)) + .as("a missing rule leaves that type on the mapper's default representation") + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("missing a rule"); + } + + @Test + @DisplayName("the committed yaml says exactly what the code says") + void theCommittedYamlSaysExactlyWhatTheCodeSays() { + // A document that drifts from the code is worse than no document, because it is the one a + // client author reads. + String yaml = readManifestResource(); + WebWireTypeManifest manifest = WebWireTypeManifest.standard(); + + for (WebWireType type : WebWireType.values()) { + WireTypeRule rule = manifest.require(type); + assertThat(yaml) + .as("%s must appear in the committed manifest", type) + .contains(type.name() + ":") + .contains("wire-format: " + rule.wireFormat()); + } + } + + @Test + @DisplayName("an enum wire value is declared, never derived from the constant name") + void anEnumWireValueIsDeclared() { + assertThat(new WebEnumValue("PARTIAL_UPDATE", "partial-update").wireValue()) + .isEqualTo("partial-update"); + assertThatThrownBy(() -> new WebEnumValue("X", "has space")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new WebEnumValue("", "value")) + .isInstanceOf(IllegalArgumentException.class); + } + + private static String readManifestResource() { + for (Path directory = Path.of("").toAbsolutePath(); + directory != null; + directory = directory.getParent()) { + Path candidate = directory.resolve("src/main/resources/META-INF/web/wire-type-manifest.yaml"); + if (Files.isRegularFile(candidate)) { + try { + return Files.readString(candidate, StandardCharsets.UTF_8); + } catch (IOException unreadable) { + throw new UncheckedIOException(unreadable); + } + } + } + throw new IllegalStateException("the committed wire type manifest is missing"); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/core/WebIdentifierTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/core/WebIdentifierTest.java new file mode 100644 index 00000000..06441218 --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/core/WebIdentifierTest.java @@ -0,0 +1,78 @@ +package dev.caskeleton.adapter.inbound.web.core; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * The core identifiers refuse the shapes that would make them useless downstream. + * + *

Each of these types exists to be a low-cardinality, log-safe key. A validation that only ran + * on the happy path would let the one value that breaks a dashboard through, so every bound is + * asserted from the rejecting side as well. + */ +class WebIdentifierTest { + + @Test + @DisplayName("an operation name outside the grammar is rejected") + void anOperationNameOutsideTheGrammarIsRejected() { + assertThatThrownBy(() -> new WebOperationName("Orders")) + .as("upper case would make Orders and orders two series in one dashboard") + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new WebOperationName("ab")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new WebOperationName("1orders")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new WebOperationName("orders/list")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new WebOperationName(null)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new WebOperationName("a".repeat(129))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("an operation name inside the grammar is accepted") + void anOperationNameInsideTheGrammarIsAccepted() { + assertThat(new WebOperationName("orders.list").value()).isEqualTo("orders.list"); + assertThat(new WebOperationName("orders-list").toString()).isEqualTo("orders-list"); + } + + @Test + @DisplayName("the api major version starts at one") + void theApiMajorVersionStartsAtOne() { + assertThat(new ApiMajorVersion(1).value()).isEqualTo(1); + assertThat(new ApiMajorVersion(2).pathSegment()).isEqualTo("v2"); + assertThatThrownBy(() -> new ApiMajorVersion(0)) + .as("v0 is a version whose contract nobody wrote down and nothing can sunset") + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new ApiMajorVersion(-1)).isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("a route id is a non-blank, bounded template") + void aRouteIdIsANonBlankBoundedTemplate() { + assertThat(new WebRouteId("GET /api/v1/orders/{orderId}").value()) + .as("the template, not a resolved URI: one route, not the thousand URIs it serves") + .isEqualTo("GET /api/v1/orders/{orderId}"); + assertThatThrownBy(() -> new WebRouteId(" ")).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new WebRouteId(null)).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new WebRouteId("x".repeat(257))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("request and trace identifiers are non-blank and bounded") + void requestAndTraceIdentifiersAreBounded() { + assertThat(new WebRequestId("req-1").value()).isEqualTo("req-1"); + assertThat(new WebTraceId("0af7651916cd43dd8448eb211c80319c").value()).hasSize(32); + assertThatThrownBy(() -> new WebRequestId(" ")).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new WebRequestId("x".repeat(129))) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new WebTraceId(null)).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new WebTraceId("x".repeat(129))) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/core/WebRequestContextTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/core/WebRequestContextTest.java new file mode 100644 index 00000000..beacf79d --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/core/WebRequestContextTest.java @@ -0,0 +1,112 @@ +package dev.caskeleton.adapter.inbound.web.core; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; +import java.time.Instant; +import java.util.Locale; +import java.util.Set; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** The request context fixes the facts a whole request path reads, and refuses impossible ones. */ +class WebRequestContextTest { + + private static final Instant NOW = Instant.parse("2026-08-13T00:00:00Z"); + + @Test + @DisplayName("a deadline before the receive time is rejected") + void aDeadlineBeforeTheReceiveTimeIsRejected() { + assertThatThrownBy(() -> context(NOW, NOW.minusSeconds(1))) + .as("a request admitted already out of budget is cancelled before any stage reports why") + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("deadline precedes receive time"); + } + + @Test + @DisplayName("the remaining budget is measured against the absolute deadline") + void theRemainingBudgetIsMeasuredAgainstTheAbsoluteDeadline() { + WebRequestContext context = context(NOW, NOW.plusSeconds(3)); + + assertThat(context.budget()).isEqualTo(Duration.ofSeconds(3)); + assertThat(context.remainingBudget(NOW.plusSeconds(1))).isEqualTo(Duration.ofSeconds(2)); + assertThat(context.remainingBudget(NOW.plusSeconds(9))) + .as("a spent budget is zero, never negative, so a caller cannot read it as more time") + .isEqualTo(Duration.ZERO); + assertThat(context.expired(NOW.plusSeconds(3))).isTrue(); + assertThat(context.expired(NOW.plusSeconds(2))).isFalse(); + } + + @Test + @DisplayName("an anonymous actor cannot carry a subject") + void anAnonymousActorCannotCarryASubject() { + assertThat(ActorContext.anonymous().authenticated()).isFalse(); + assertThat(ActorContext.authenticated("user-1", Set.of("ROLE_USER")).subject()) + .isEqualTo("user-1"); + assertThatThrownBy(() -> new ActorContext("user-1", false, Set.of())) + .as("an unverified identifier that reaches an audit record looking verified is the risk") + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> ActorContext.authenticated(" ", Set.of())) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("a tenant is resolved or absent, never a caller-supplied blank") + void aTenantIsResolvedOrAbsent() { + assertThat(TenantContext.none().value()).isEmpty(); + assertThat(TenantContext.resolved("acme").value()).contains("acme"); + assertThatThrownBy(() -> TenantContext.resolved(" ")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new TenantContext("../other")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("the external request keeps only a normalised origin and prefix") + void theExternalRequestKeepsOnlyANormalisedOriginAndPrefix() { + ExternalRequestContext external = + new ExternalRequestContext("HTTPS", "API.Example.COM", 443, "/gateway"); + + assertThat(external.scheme()).isEqualTo("https"); + assertThat(external.host()).isEqualTo("api.example.com"); + assertThat(external.origin()) + .as("the default port is left implicit so generated URLs match what a client typed") + .isEqualTo("https://api.example.com"); + assertThat(external.baseUrl()).isEqualTo("https://api.example.com/gateway"); + assertThat(new ExternalRequestContext("http", "localhost", 8080, "").baseUrl()) + .isEqualTo("http://localhost:8080"); + } + + @Test + @DisplayName("an external prefix that could redirect a generated URL is rejected") + void anExternalPrefixThatCouldRedirectAGeneratedUrlIsRejected() { + assertThatThrownBy(() -> new ExternalRequestContext("https", "api.example.com", 443, "gateway")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy( + () -> new ExternalRequestContext("https", "api.example.com", 443, "/gateway/")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy( + () -> new ExternalRequestContext("https", "api.example.com", 443, "/../evil")) + .as("a prefix is used to build Location headers, so traversal in it chooses a target") + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new ExternalRequestContext("ftp", "api.example.com", 21, "")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new ExternalRequestContext("https", "api.example.com", 0, "")) + .isInstanceOf(IllegalArgumentException.class); + } + + private static WebRequestContext context(Instant receivedAt, Instant deadline) { + return new WebRequestContext( + new WebRequestId("req-1"), + new WebTraceId("0af7651916cd43dd8448eb211c80319c"), + new WebOperationName("orders.list"), + new ApiMajorVersion(1), + ActorContext.anonymous(), + TenantContext.none(), + Locale.ENGLISH, + receivedAt, + deadline, + new ExternalRequestContext("https", "api.example.com", 443, "")); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/error/ProblemCatalogTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/error/ProblemCatalogTest.java new file mode 100644 index 00000000..515d315f --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/error/ProblemCatalogTest.java @@ -0,0 +1,99 @@ +package dev.caskeleton.adapter.inbound.web.error; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.EnumMap; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** One code, one status, everywhere, and the published document says the same. */ +class ProblemCatalogTest { + + private final ProblemCatalog catalog = ProblemCatalog.standard(); + + @Test + @DisplayName("validation is 422 and a precondition is 412") + void validationIs422AndAPreconditionIs412() { + assertThat(catalog.require(ProblemCode.VALIDATION_FAILED).status()).isEqualTo(422); + assertThat(catalog.require(ProblemCode.PRECONDITION_FAILED).status()).isEqualTo(412); + assertThat(catalog.require(ProblemCode.MALFORMED_REQUEST).status()) + .as("a document that is not JSON is a 400, not a 422") + .isEqualTo(400); + } + + @Test + @DisplayName("a dependency failure and a dependency timeout are different statuses") + void aDependencyFailureAndTimeoutAreDifferentStatuses() { + assertThat(catalog.require(ProblemCode.DEPENDENCY_FAILURE).status()).isEqualTo(502); + assertThat(catalog.require(ProblemCode.DEPENDENCY_TIMEOUT).status()) + .as("504 tells a caller the request may still be running; 502 tells it the request is not") + .isEqualTo(504); + } + + @Test + @DisplayName("every code is defined, and a partial catalog is refused") + void everyCodeIsDefined() { + for (ProblemCode code : ProblemCode.values()) { + assertThat(catalog.require(code)).isNotNull(); + } + Map partial = new EnumMap<>(ProblemCode.class); + partial.put(ProblemCode.INTERNAL_ERROR, catalog.require(ProblemCode.INTERNAL_ERROR)); + + assertThatThrownBy(() -> new ProblemCatalog(partial)) + .as("an undefined code becomes a status somebody picks at the call site") + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("missing a definition"); + } + + @Test + @DisplayName("every status is a 4xx or 5xx and every type shares the published namespace") + void everyStatusIsAFailureAndEveryTypeIsNamespaced() { + catalog + .definitions() + .forEach( + (code, definition) -> { + assertThat(definition.status()).isBetween(400, 599); + assertThat(definition.type().toString()).startsWith(ProblemCatalog.TYPE_PREFIX); + assertThat(definition.title()).isNotBlank(); + }); + } + + @Test + @DisplayName("the published document says exactly what the catalog says") + void thePublishedDocumentSaysExactlyWhatTheCatalogSays() { + String yaml = readCatalogResource(); + + catalog + .definitions() + .forEach( + (code, definition) -> + assertThat(yaml) + .as("%s must appear in the published catalog", code) + .contains(code.name() + ":") + .contains("type: " + definition.type()) + .contains("status: " + definition.status())); + } + + private static String readCatalogResource() { + for (Path directory = Path.of("").toAbsolutePath(); + directory != null; + directory = directory.getParent()) { + Path candidate = directory.resolve("src/main/resources/META-INF/web/problem-catalog.yaml"); + if (Files.isRegularFile(candidate)) { + try { + return Files.readString(candidate, StandardCharsets.UTF_8); + } catch (IOException unreadable) { + throw new UncheckedIOException(unreadable); + } + } + } + throw new IllegalStateException("the published problem catalog is missing"); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/error/WebProblemFactoryTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/error/WebProblemFactoryTest.java new file mode 100644 index 00000000..58e1e305 --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/error/WebProblemFactoryTest.java @@ -0,0 +1,122 @@ +package dev.caskeleton.adapter.inbound.web.error; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.net.URI; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * The factory is the only door, and everything that goes through it comes out publishable. + * + *

Each case here is a real leak shape: a stack trace in an exception message, a + * package-qualified class name, a connection URL, a bearer token echoed from a header, a fragment + * of SQL. + */ +class WebProblemFactoryTest { + + private final WebProblemFactory factory = WebProblemFactory.standard(); + + @Test + @DisplayName("a stack trace is stripped and the stable code survives") + void aStackTraceIsStrippedAndTheStableCodeSurvives() { + WebProblem problem = + factory.create( + ProblemCode.INTERNAL_ERROR, + "java.lang.IllegalStateException: broke" + + NEWLINE + + " at internal.Service.run(Service.java:42)", + URI.create("/problems/p1"), + "trace-1", + List.of()); + + assertThat(problem.status()).isEqualTo(500); + assertThat(problem.detail()).doesNotContain("internal.Service"); + assertThat(problem.detail()).doesNotContain("Service.java"); + assertThat(problem.code()).isEqualTo(ProblemCode.INTERNAL_ERROR); + assertThat(problem.traceId()).isEqualTo("trace-1"); + } + + @Test + @DisplayName("a connection url, a credential and a query fragment are all removed") + void internalDetailsAreRemoved() { + WebProblemSanitizer sanitizer = new WebProblemSanitizer(); + + assertThat(sanitizer.sanitize("failed against jdbc://db-primary.internal:5432/orders", 256)) + .doesNotContain("db-primary.internal"); + assertThat(sanitizer.sanitize("Authorization: Bearer eyJhbGciOi", 256)) + .doesNotContain("eyJhbGciOi"); + assertThat(sanitizer.sanitize("select secret from users where id = 4", 256)) + .doesNotContain("users"); + assertThat(sanitizer.sanitize("/etc/secrets/app/token", 256)).doesNotContain("/etc/secrets"); + } + + @Test + @DisplayName("detail is bounded by the catalog and never empty") + void detailIsBoundedAndNeverEmpty() { + WebProblem problem = + factory.create( + ProblemCode.AUTHENTICATION_REQUIRED, + "x".repeat(1000), + URI.create("/problems/p2"), + "trace-2", + List.of()); + + assertThat(problem.detail()).hasSizeLessThanOrEqualTo(128); + assertThat( + factory + .create(ProblemCode.ACCESS_DENIED, null, URI.create("/p"), "t", List.of()) + .detail()) + .isEqualTo(WebProblemSanitizer.REDACTED); + assertThat(new WebProblemSanitizer().sanitize(" ", 64)) + .isEqualTo(WebProblemSanitizer.REDACTED); + } + + @Test + @DisplayName("a status disagreement fails before serialization") + void aStatusDisagreementFailsBeforeSerialization() { + WebProblem problem = + factory.create(ProblemCode.RESOURCE_CONFLICT, "conflict", URI.create("/p"), "t", List.of()); + + assertThatCode(() -> factory.requireStatusAgreement(409, problem)).doesNotThrowAnyException(); + assertThatThrownBy(() -> factory.requireStatusAgreement(500, problem)) + .as("a client will branch on one of them and cannot be told which") + .isInstanceOf(ProblemStatusMismatchException.class); + } + + @Test + @DisplayName("only the two published extensions are allowed") + void onlyThePublishedExtensionsAreAllowed() { + assertThat(SafeProblemDetailExtensions.allowed("traceId")).isTrue(); + assertThat(SafeProblemDetailExtensions.allowed("errors")).isTrue(); + assertThat(SafeProblemDetailExtensions.allowed("cause")) + .as("an open extension set is how an exception message ships to every caller") + .isFalse(); + assertThatThrownBy(() -> SafeProblemDetailExtensions.requireAllowed("stackTrace")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("a validation issue points with a JSON Pointer and carries no submitted value") + void aValidationIssuePointsWithAJsonPointer() { + ValidationIssue issue = + new ValidationIssue("/order/lines/0/quantity", "must-be-positive", "must be positive"); + + assertThat(issue.pointer()).startsWith("/"); + assertThatThrownBy(() -> new ValidationIssue("createOrder.arg0.quantity", "c", "m")) + .as("a Java property path is unusable to a client and discloses an internal signature") + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("the factory reports the status a code is always answered with") + void theFactoryReportsTheStatusForACode() { + assertThat(factory.statusFor(ProblemCode.RATE_LIMITED)).isEqualTo(429); + assertThat(factory.statusFor(ProblemCode.VALIDATION_FAILED)).isEqualTo(422); + } + + private static final String NEWLINE = "\n"; +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/evidence/WebExecutionEvidenceTrackerTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/evidence/WebExecutionEvidenceTrackerTest.java new file mode 100644 index 00000000..9dc9f241 --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/evidence/WebExecutionEvidenceTrackerTest.java @@ -0,0 +1,156 @@ +package dev.caskeleton.adapter.inbound.web.evidence; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * The three axes stay three axes, and none of them runs backwards. + * + *

The case each of these protects is the same one: a request that committed and then failed to + * answer. A model that collapses the axes, or that lets a later stage lower an earlier verdict, + * reports that request as retryable — and the retry is a duplicate write. + */ +class WebExecutionEvidenceTrackerTest { + + @Test + @DisplayName("a completed local write never claims the client observed it") + void aCompletedLocalWriteNeverClaimsTheClientObservedIt() { + WebExecutionEvidenceTracker tracker = WebExecutionEvidenceTracker.received(); + tracker.markApplicationCommitted(); + tracker.markLocalResponseWriteCompleted(); + + WebExecutionEvidence evidence = tracker.snapshot(); + + assertThat(evidence.responseEvidence()) + .isEqualTo(WebResponseEvidence.RESPONSE_WRITE_COMPLETED_LOCALLY); + assertThat(evidence.responseEvidence()) + .as("no value on this axis may be read as proof the client received anything") + .isNotEqualTo(WebResponseEvidence.CLIENT_OBSERVATION_UNKNOWN); + } + + @Test + @DisplayName("the axes advance independently of one another") + void theAxesAdvanceIndependently() { + WebExecutionEvidenceTracker tracker = WebExecutionEvidenceTracker.received(); + + tracker.markApplicationCommitted(); + + assertThat(tracker.snapshot().requestPhase()) + .as("committing the application says nothing about how admission went") + .isEqualTo(WebRequestPhase.HTTP_RECEIVED); + assertThat(tracker.snapshot().responseEvidence()) + .as("committing the application says nothing about the response") + .isEqualTo(WebResponseEvidence.NOT_COMMITTED); + } + + @Test + @DisplayName("an evidence transition never runs backwards") + void anEvidenceTransitionNeverRunsBackwards() { + WebExecutionEvidenceTracker tracker = WebExecutionEvidenceTracker.received(); + tracker.markPhase(WebRequestPhase.REQUEST_ADMITTED); + tracker.markApplicationCommitted(); + tracker.markLocalResponseWriteCompleted(); + + tracker.markPhase(WebRequestPhase.ROUTE_SELECTED); + tracker.markApplicationStarted(); + tracker.markResponseHeadersCommitted(); + + WebExecutionEvidence evidence = tracker.snapshot(); + assertThat(evidence.requestPhase()).isEqualTo(WebRequestPhase.REQUEST_ADMITTED); + assertThat(evidence.applicationEvidence()) + .as("a late report must not lower a commit to started; that is a duplicate write") + .isEqualTo(WebApplicationEvidence.APPLICATION_COMMITTED); + assertThat(evidence.responseEvidence()) + .isEqualTo(WebResponseEvidence.RESPONSE_WRITE_COMPLETED_LOCALLY); + } + + @Test + @DisplayName("only a proven-untouched application is safe to retry") + void onlyAProvenUntouchedApplicationIsSafeToRetry() { + assertThat(evidenceWith(WebApplicationEvidence.NOT_STARTED).safeToRetryMutation()).isTrue(); + assertThat(evidenceWith(WebApplicationEvidence.APPLICATION_ROLLED_BACK).safeToRetryMutation()) + .isTrue(); + assertThat(evidenceWith(WebApplicationEvidence.APPLICATION_STARTED).safeToRetryMutation()) + .as("entered and unobserved may have committed") + .isFalse(); + assertThat(evidenceWith(WebApplicationEvidence.APPLICATION_UNKNOWN).safeToRetryMutation()) + .isFalse(); + assertThat(evidenceWith(WebApplicationEvidence.APPLICATION_COMMITTED).safeToRetryMutation()) + .isFalse(); + } + + @Test + @DisplayName("a commit whose response was not completed locally needs reconciliation") + void aCommitWhoseResponseWasNotCompletedNeedsReconciliation() { + WebExecutionEvidenceTracker tracker = WebExecutionEvidenceTracker.received(); + tracker.markApplicationCommitted(); + tracker.markResponseHeadersCommitted(); + + assertThat(tracker.snapshot().requiresReconciliation()).isTrue(); + + tracker.markLocalResponseWriteCompleted(); + assertThat(tracker.snapshot().requiresReconciliation()).isFalse(); + + WebExecutionEvidenceTracker unknown = WebExecutionEvidenceTracker.received(); + unknown.markApplicationUnknown(); + unknown.markLocalResponseWriteCompleted(); + assertThat(unknown.snapshot().requiresReconciliation()) + .as("an unobserved application outcome is never settled by a successful write") + .isTrue(); + } + + @Test + @DisplayName("concurrent reporters cannot lose the strongest evidence") + void concurrentReportersCannotLoseTheStrongestEvidence() throws Exception { + // A container thread can complete a request while an async dispatch is still reporting, so the + // transition has to be a CAS rather than a read-modify-write. + WebExecutionEvidenceTracker tracker = WebExecutionEvidenceTracker.received(); + int reporters = 32; + CountDownLatch start = new CountDownLatch(1); + ExecutorService pool = Executors.newFixedThreadPool(8); + List> reports = new ArrayList<>(reporters); + try { + for (int i = 0; i < reporters; i++) { + boolean strong = i % 2 == 0; + reports.add( + pool.submit( + () -> { + start.await(5, TimeUnit.SECONDS); + if (strong) { + tracker.markApplicationCommitted(); + } else { + tracker.markApplicationStarted(); + } + return null; + })); + } + start.countDown(); + // Each future is read rather than discarded: a reporter that threw would otherwise leave the + // assertion below passing on evidence nobody actually recorded. + for (Future report : reports) { + report.get(10, TimeUnit.SECONDS); + } + pool.shutdown(); + assertThat(pool.awaitTermination(10, TimeUnit.SECONDS)).isTrue(); + } finally { + pool.shutdownNow(); + } + + assertThat(tracker.snapshot().applicationEvidence()) + .isEqualTo(WebApplicationEvidence.APPLICATION_COMMITTED); + } + + private static WebExecutionEvidence evidenceWith(WebApplicationEvidence application) { + return new WebExecutionEvidence( + WebRequestPhase.REQUEST_ADMITTED, application, WebResponseEvidence.NOT_COMMITTED); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/fixtures/badcontroller/FixtureTransactional.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/fixtures/badcontroller/FixtureTransactional.java new file mode 100644 index 00000000..31680e53 --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/fixtures/badcontroller/FixtureTransactional.java @@ -0,0 +1,20 @@ +package dev.caskeleton.adapter.inbound.web.fixtures.badcontroller; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Stands in for a transaction annotation the fixture can carry. + * + *

The real rule names {@code org.springframework.transaction.annotation.Transactional} and + * {@code jakarta.transaction.Transactional}, neither of which is on this leaf's classpath — which + * is itself the point, since a controller here cannot open a transaction it cannot import. The + * condition matches annotations by fully qualified name, so a fixture annotation exercises exactly + * the same code path, and the production rule is run against the real graph at the composition root + * where the real annotations exist. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.TYPE, ElementType.METHOD}) +public @interface FixtureTransactional {} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/fixtures/badcontroller/RepositoryHoldingController.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/fixtures/badcontroller/RepositoryHoldingController.java new file mode 100644 index 00000000..e25217a9 --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/fixtures/badcontroller/RepositoryHoldingController.java @@ -0,0 +1,30 @@ +package dev.caskeleton.adapter.inbound.web.fixtures.badcontroller; + +import java.sql.Connection; + +/** + * The violation the controller rule is supposed to catch, written down once. + * + *

A negative fixture rather than a comment. A boundary rule that has never been shown to fail is + * indistinguishable from one that scans the wrong package. + * + *

It reaches persistence through {@code java.sql} rather than through Spring Data, and that is + * deliberate: adding Spring Data to this leaf to write the fixture would put the very dependency on + * the classpath that the rule exists to keep off it. {@code java.sql} is in the same forbidden + * family, is in the JDK, and makes the same point. + */ +@FixtureTransactional +public class RepositoryHoldingController { + + private final Connection connection; + + public RepositoryHoldingController(Connection connection) { + this.connection = connection; + } + + /** Reaches persistence directly, which is the point of the fixture. */ + @FixtureTransactional + public Connection load() { + return connection; + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/http/ExternalUriBuilderTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/http/ExternalUriBuilderTest.java new file mode 100644 index 00000000..3728ba0f --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/http/ExternalUriBuilderTest.java @@ -0,0 +1,141 @@ +package dev.caskeleton.adapter.inbound.web.http; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.inbound.web.proxy.NormalizedForwardedHeaders; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * A URL this API hands back is a URL a client will follow, so both ways of getting it wrong are + * silent: a doubled prefix produces a 404 the client cannot diagnose, and a header-derived host + * produces a link that points wherever the caller said. + */ +class ExternalUriBuilderTest { + + private static final ExternalOrigin SERVER = new ExternalOrigin("http", "10.0.0.4", 8080); + + @Test + @DisplayName("the prefix is applied exactly once") + void thePrefixIsAppliedExactlyOnce() { + ExternalUriBuilder builder = + new ExternalUriBuilder(ExternalOrigin.https("api.example.com"), new ExternalPrefix("/api")); + + assertThat(builder.relative("/v1/orders/1")).isEqualTo("/api/v1/orders/1"); + assertThat(builder.relative("/api/v1/orders/1")) + .as("a path the gateway already prefixed must not be prefixed again") + .isEqualTo("/api/v1/orders/1"); + assertThat(builder.absolute("/v1/orders/1")) + .isEqualTo("https://api.example.com/api/v1/orders/1"); + } + + @Test + @DisplayName("prefix matching is segment-aware") + void prefixMatchingIsSegmentAware() { + ExternalUriBuilder builder = + new ExternalUriBuilder(ExternalOrigin.https("api.example.com"), new ExternalPrefix("/api")); + + assertThat(builder.relative("/apiary/bees")) + .as("/apiary is not an already-prefixed /api path, and treating it as one loses a segment") + .isEqualTo("/api/apiary/bees"); + } + + @Test + @DisplayName("a configured origin beats anything a proxy says") + void aConfiguredOriginBeatsAnythingAProxySays() { + ExternalUriPolicy policy = + ExternalUriPolicy.configured( + ExternalOrigin.https("api.example.com"), ExternalPrefix.none()); + NormalizedForwardedHeaders hostile = + NormalizedForwardedHeaders.fromTrustedHeaders( + Map.of("X-Forwarded-Host", "evil.example", "X-Forwarded-Proto", "http")); + + assertThat(policy.resolveOrigin(hostile, SERVER).value()) + .as("a reset link must point where the deployment says, not where a header does") + .isEqualTo("https://api.example.com"); + } + + @Test + @DisplayName("a gateway deployment learns its public name from the trusted proxy") + void aGatewayDeploymentLearnsItsPublicNameFromTheProxy() { + ExternalUriPolicy policy = ExternalUriPolicy.fromTrustedProxy(ExternalPrefix.none()); + NormalizedForwardedHeaders forwarded = + NormalizedForwardedHeaders.fromTrustedHeaders( + Map.of("X-Forwarded-Host", "api.example.com", "X-Forwarded-Proto", "https")); + + ExternalOrigin resolved = policy.resolveOrigin(forwarded, SERVER); + + assertThat(resolved.value()).isEqualTo("https://api.example.com"); + assertThat(resolved.port()) + .as("the server's own 8080 is the internal port and means nothing externally") + .isEqualTo(443); + } + + @Test + @DisplayName("with no forwarded facts the server's own socket is used") + void withNoForwardedFactsTheServerSocketIsUsed() { + ExternalUriPolicy policy = ExternalUriPolicy.fromTrustedProxy(ExternalPrefix.none()); + + assertThat(policy.resolveOrigin(NormalizedForwardedHeaders.none(), SERVER).value()) + .isEqualTo("http://10.0.0.4:8080"); + } + + @Test + @DisplayName("a configured prefix beats a forwarded one") + void aConfiguredPrefixBeatsAForwardedOne() { + NormalizedForwardedHeaders forwarded = + NormalizedForwardedHeaders.fromTrustedHeaders(Map.of("X-Forwarded-Prefix", "/gateway")); + + assertThat( + ExternalUriPolicy.configured( + ExternalOrigin.https("api.example.com"), new ExternalPrefix("/api")) + .resolvePrefix(forwarded) + .value()) + .isEqualTo("/api"); + assertThat( + ExternalUriPolicy.fromTrustedProxy(ExternalPrefix.none()) + .resolvePrefix(forwarded) + .value()) + .isEqualTo("/gateway"); + } + + @Test + @DisplayName("a prefix is normalised and a traversing one is refused") + void aPrefixIsNormalisedAndATraversingOneIsRefused() { + assertThat(new ExternalPrefix("/api/").value()).isEqualTo("/api"); + assertThat(ExternalPrefix.none().empty()).isTrue(); + assertThatThrownBy(() -> new ExternalPrefix("api")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new ExternalPrefix("/../admin")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("Location is absolute, and a relative application path is required") + void locationIsAbsolute() { + ExternalUriBuilder builder = + new ExternalUriBuilder(ExternalOrigin.https("api.example.com"), ExternalPrefix.none()); + + assertThat(builder.location("/v1/orders/1")) + .as( + "intermediaries have historically disagreed about what a relative Location is relative to") + .isEqualTo("https://api.example.com/v1/orders/1"); + assertThatThrownBy(() -> builder.relative("v1/orders/1")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("a default port is implicit and a non-default one is not") + void aDefaultPortIsImplicit() { + assertThat(ExternalOrigin.https("api.example.com").value()) + .isEqualTo("https://api.example.com"); + assertThat(new ExternalOrigin("http", "api.example.com", 80).value()) + .isEqualTo("http://api.example.com"); + assertThat(new ExternalOrigin("https", "api.example.com", 8443).value()) + .isEqualTo("https://api.example.com:8443"); + assertThatThrownBy(() -> new ExternalOrigin("ftp", "h", 21)) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/http/WebResponseContractTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/http/WebResponseContractTest.java new file mode 100644 index 00000000..81495ad1 --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/http/WebResponseContractTest.java @@ -0,0 +1,81 @@ +package dev.caskeleton.adapter.inbound.web.http; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** The status rules are enforced where a response is built, not reported after it is written. */ +class WebResponseContractTest { + + private final WebResponseContract contract = new WebResponseContract(); + + @Test + @DisplayName("204 and 304 cannot carry a body") + void noContentCannotCarryABody() { + assertThatThrownBy(() -> contract.validate(204, Map.of(), "body")) + .as("some intermediaries drop it and some forward it, so behaviour depends on the proxy") + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> contract.validate(304, Map.of("ETag", "\"v1\""), "body")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("201 and 202 require a Location") + void createdAndAcceptedRequireALocation() { + assertThatThrownBy(() -> contract.validate(201, Map.of(), new Object())) + .as("without it the client is told something exists and not where") + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> contract.validate(202, Map.of(), new Object())) + .isInstanceOf(IllegalArgumentException.class); + assertThatCode( + () -> contract.validate(201, Map.of("Location", "/api/v1/orders/1"), new Object())) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("304 must repeat the validator it matched") + void notModifiedMustRepeatTheValidator() { + assertThatThrownBy(() -> contract.validate(304, Map.of(), null)) + .as("otherwise the next conditional read has no validator to send") + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("the factories build only responses the contract allows") + void theFactoriesBuildOnlyAllowedResponses() { + assertThat(WebSuccessResponse.created("/api/v1/orders/1", "body").location()) + .contains("/api/v1/orders/1"); + assertThat(WebSuccessResponse.noContent().body()).isNull(); + assertThat(WebSuccessResponse.notModified("\"v1\"").headers()).containsKey("ETag"); + assertThat(WebSuccessResponse.ok("body").status()).isEqualTo(200); + } + + @Test + @DisplayName("a HEAD response keeps the GET headers exactly and drops only the body") + void aHeadResponseKeepsTheHeadersExactly() { + WebSuccessResponse get = + new WebSuccessResponse<>(200, Map.of("ETag", "\"v1\"", "Vary", "Accept"), "body"); + + WebSuccessResponse head = get.asHeadResponse(); + + assertThat(head.body()).isNull(); + assertThat(head.headers()) + .as("a HEAD whose headers differ from its GET defeats the only reason to send one") + .isEqualTo(get.headers()); + } + + @Test + @DisplayName("a non-success status is not a success response") + void aNonSuccessStatusIsNotASuccessResponse() { + assertThatThrownBy(() -> new WebSuccessResponse<>(500, Map.of(), "body")) + .isInstanceOf(IllegalArgumentException.class); + assertThat(WebStatusContract.forbidsBody(204)).isTrue(); + assertThat(WebStatusContract.requiresLocation(202)).isTrue(); + assertThat(WebStatusContract.success(204)).isTrue(); + assertThat(WebStatusContract.success(304)).isFalse(); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/http/WebUriPolicyTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/http/WebUriPolicyTest.java new file mode 100644 index 00000000..002fe95c --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/http/WebUriPolicyTest.java @@ -0,0 +1,137 @@ +package dev.caskeleton.adapter.inbound.web.http; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.inbound.web.operation.HttpMethodSemantic; +import java.util.Set; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * One path spelling, one method allowlist. + * + *

Each rejection here is a way for a proxy and this application to disagree about which route a + * request reached, which is how an authorization rule ends up applied to a path nobody served. + */ +class WebUriPolicyTest { + + private final WebUriPolicy policy = WebUriPolicy.standard(); + + @Test + @DisplayName("an encoded slash or a duplicate slash is refused") + void anEncodedSlashOrADuplicateSlashIsRefused() { + assertThatThrownBy(() -> policy.canonicalize("/api/v1/documents%2Fsecret")) + .as("an encoded separator smuggles a segment boundary past a prefix rule") + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> policy.canonicalize("/api/v1/documents%2fsecret")) + .as("the check must not be defeated by lower case") + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> policy.canonicalize("/api//v1/documents")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("a matrix parameter, dot segment or backslash is refused") + void nonCanonicalSegmentsAreRefused() { + assertThatThrownBy(() -> policy.canonicalize("/api/v1/documents;jsessionid=abc")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> policy.canonicalize("/api/v1/../admin")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> policy.canonicalize("/api/v1/./documents")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> policy.canonicalize("/api" + BACKSLASH + "v1")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("a control character in a path is refused") + void aControlCharacterInAPathIsRefused() { + assertThatThrownBy(() -> policy.canonicalize("/api/v1/doc" + NEWLINE)) + .as("a bare newline in a path is a request-splitting attempt, not a path") + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("a trailing slash is not the same resource") + void aTrailingSlashIsNotTheSameResource() { + assertThatThrownBy(() -> policy.canonicalize("/api/v1/documents/")) + .as("one resource must have one name, or a cache holds it twice") + .isInstanceOf(IllegalArgumentException.class); + assertThatCode(() -> policy.canonicalize("/")).doesNotThrowAnyException(); + } + + @Test + @DisplayName("paths stay case-sensitive") + void pathsStayCaseSensitive() { + assertThat(policy.canonicalize("/api/v1/Documents").value()) + .as("case-folding would make /Admin reach /admin, a route nobody published") + .isEqualTo("/api/v1/Documents"); + } + + @Test + @DisplayName("a relative or oversized path is refused") + void aRelativeOrOversizedPathIsRefused() { + assertThatThrownBy(() -> policy.canonicalize("api/v1")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> policy.canonicalize(null)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> policy.canonicalize("/" + "a".repeat(2048))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("the canonical form is accepted and carried as a type") + void theCanonicalFormIsAccepted() { + CanonicalPath path = policy.canonicalize("/api/v1/documents/42"); + + assertThat(path.value()).isEqualTo("/api/v1/documents/42"); + assertThat(path.toString()).isEqualTo("/api/v1/documents/42"); + assertThatThrownBy(() -> new CanonicalPath("relative")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("TRACE, CONNECT and custom verbs are outside the standard allowlist") + void nonStandardVerbsAreRefused() { + WebMethodPolicy methods = WebMethodPolicy.standard(); + + assertThat(methods.allows("TRACE")) + .as("TRACE reflects headers the client never set, which is how httpOnly stops meaning it") + .isFalse(); + assertThat(methods.allows("CONNECT")).isFalse(); + assertThat(methods.allows("PURGE")).isFalse(); + assertThatThrownBy(() -> methods.require("TRACE")).isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("the seven standard methods resolve, case-sensitively") + void theSevenStandardMethodsResolve() { + WebMethodPolicy methods = WebMethodPolicy.standard(); + + assertThat(methods.require("GET")).isEqualTo(HttpMethodSemantic.GET); + assertThat(methods.require("PATCH")).isEqualTo(HttpMethodSemantic.PATCH); + assertThat(methods.allows("get")) + .as("a proxy rule written for GET does not apply to get") + .isFalse(); + assertThat(methods.allowed()).hasSize(7); + assertThat(methods.allowHeaderValue()) + .isEqualTo("DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT"); + } + + @Test + @DisplayName("a narrowed policy serves only what it declares") + void aNarrowedPolicyServesOnlyWhatItDeclares() { + WebMethodPolicy readOnly = + WebMethodPolicy.allowing(Set.of(HttpMethodSemantic.GET, HttpMethodSemantic.HEAD)); + + assertThat(readOnly.allows("POST")).isFalse(); + assertThat(readOnly.allowHeaderValue()).isEqualTo("GET, HEAD"); + assertThatThrownBy(() -> WebMethodPolicy.allowing(Set.of())) + .isInstanceOf(IllegalArgumentException.class); + } + + private static final String BACKSLASH = "\\"; + private static final String NEWLINE = "\n"; +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/idempotency/InMemoryIdempotencyStore.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/idempotency/InMemoryIdempotencyStore.java new file mode 100644 index 00000000..6b411244 --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/idempotency/InMemoryIdempotencyStore.java @@ -0,0 +1,70 @@ +package dev.caskeleton.adapter.inbound.web.idempotency; + +import dev.caskeleton.application.idempotency.IdempotencyRecord; +import dev.caskeleton.application.idempotency.IdempotencyScope; +import dev.caskeleton.application.idempotency.IdempotencyStatus; +import dev.caskeleton.application.idempotency.IdempotencyStorePort; +import dev.caskeleton.application.idempotency.RequestFingerprint; +import dev.caskeleton.application.idempotency.StoredResponse; +import java.time.Instant; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +/** + * A store with the one property the gate depends on: {@code tryBegin} is atomic. + * + *

Backed by {@code putIfAbsent} rather than a get-then-put, because a fake whose claim is not + * atomic would let the concurrency test pass while the real contract the JPA adapter must satisfy + * goes unstated. + */ +class InMemoryIdempotencyStore implements IdempotencyStorePort { + + // Keyed by the scope record, not by storageKey(): that string joins its four dimensions with + // "::" and escapes nothing, so a fake keyed on it would silently merge two scopes that the real + // adapter — which has a unique constraint per dimension column — keeps apart. + private final Map records = new ConcurrentHashMap<>(); + + @Override + public boolean tryBegin( + IdempotencyScope scope, RequestFingerprint fingerprint, Instant expiresAt) { + IdempotencyRecord claim = + new IdempotencyRecord( + scope, fingerprint, IdempotencyStatus.IN_FLIGHT, null, Instant.EPOCH, expiresAt); + return records.putIfAbsent(scope, claim) == null; + } + + @Override + public Optional find(IdempotencyScope scope, Instant now) { + return Optional.ofNullable(records.get(scope)).filter(record -> !record.isExpiredAt(now)); + } + + @Override + public void complete(IdempotencyScope scope, StoredResponse response) { + records.computeIfPresent( + scope, + (key, record) -> + new IdempotencyRecord( + record.scope(), + record.fingerprint(), + IdempotencyStatus.COMPLETED, + response, + record.createdAt(), + record.expiresAt())); + } + + @Override + public void discard(IdempotencyScope scope) { + records.remove(scope); + } + + /** Drops a record without going through the port, standing in for TTL expiry. */ + void expire(IdempotencyScope scope) { + records.remove(scope); + } + + /** How many records are held. */ + int size() { + return records.size(); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/idempotency/SemanticRequestFingerprintFactoryTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/idempotency/SemanticRequestFingerprintFactoryTest.java new file mode 100644 index 00000000..03ddbb79 --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/idempotency/SemanticRequestFingerprintFactoryTest.java @@ -0,0 +1,140 @@ +package dev.caskeleton.adapter.inbound.web.idempotency; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.inbound.web.core.WebOperationName; +import dev.caskeleton.adapter.inbound.web.json.WebObjectMapperFactory; +import dev.caskeleton.application.idempotency.RequestFingerprint; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * A shared fingerprint means the second request is answered with the first one's receipt. + * + *

Each case is one way two different requests could end up sharing one, or one way a legitimate + * retry could stop matching itself. + */ +class SemanticRequestFingerprintFactoryTest { + + private static final WebOperationName TRANSFER = new WebOperationName("transfers.create"); + + private final SemanticRequestFingerprintFactory factory = + new SemanticRequestFingerprintFactory( + new DeterministicCommandEncoder(WebObjectMapperFactory.standard()), + FingerprintHeaderPolicy.standard()); + + record Transfer(String to, int amount) {} + + @Test + @DisplayName("field order does not change the fingerprint") + void fieldOrderDoesNotChangeTheFingerprint() { + Map oneOrder = new LinkedHashMap<>(); + oneOrder.put("to", "acc-2"); + oneOrder.put("amount", 10); + Map otherOrder = new LinkedHashMap<>(); + otherOrder.put("amount", 10); + otherOrder.put("to", "acc-2"); + + assertThat(fingerprint(Map.of(), oneOrder)) + .as("a client library that reorders members must not turn a retry into a new request") + .isEqualTo(fingerprint(Map.of(), otherOrder)); + } + + @Test + @DisplayName("a different path identifier is a different request") + void aDifferentPathIdentifierIsADifferentRequest() { + assertThat(fingerprint(Map.of("accountId", "1"), new Transfer("acc-2", 10))) + .as("identical bodies under different accounts must not share a stored receipt") + .isNotEqualTo(fingerprint(Map.of("accountId", "2"), new Transfer("acc-2", 10))); + } + + @Test + @DisplayName("a different body is a different request") + void aDifferentBodyIsADifferentRequest() { + assertThat(fingerprint(Map.of(), new Transfer("acc-2", 10))) + .isNotEqualTo(fingerprint(Map.of(), new Transfer("acc-2", 5))); + assertThat(fingerprint(Map.of(), new Transfer("acc-2", 10))) + .isNotEqualTo(fingerprint(Map.of(), new Transfer("acc-3", 10))); + } + + @Test + @DisplayName("array order does change the fingerprint") + void arrayOrderDoesChangeTheFingerprint() { + assertThat(fingerprint(Map.of(), Map.of("lines", java.util.List.of("a", "b")))) + .as("an array is a sequence, and reordering it changes what was asked for") + .isNotEqualTo(fingerprint(Map.of(), Map.of("lines", java.util.List.of("b", "a")))); + } + + @Test + @DisplayName("a credential or a per-exchange header never contributes") + void aCredentialOrPerExchangeHeaderNeverContributes() { + RequestFingerprint withNoise = + factory.create( + TRANSFER, + Map.of(), + new Transfer("acc-2", 10), + Map.of( + "Authorization", "Bearer abc", + "traceparent", "00-aaa-bbb-01", + "X-Request-Id", "req-1", + "User-Agent", "curl")); + + assertThat(withNoise.hex()) + .as("a retry differs in all of these, and a stored digest of a credential is a credential") + .isEqualTo(fingerprint(Map.of(), new Transfer("acc-2", 10)).hex()); + assertThat(FingerprintHeaderPolicy.standard().contributes("Authorization")).isFalse(); + assertThat(FingerprintHeaderPolicy.standard().contributes("Content-Type")).isTrue(); + } + + @Test + @DisplayName("a semantic header does contribute") + void aSemanticHeaderDoesContribute() { + assertThat( + factory.create( + TRANSFER, + Map.of(), + new Transfer("acc-2", 10), + Map.of("Content-Type", "application/json"))) + .isNotEqualTo( + factory.create( + TRANSFER, + Map.of(), + new Transfer("acc-2", 10), + Map.of("Content-Type", "application/merge-patch+json"))); + } + + @Test + @DisplayName("a credential cannot be added to the policy") + void aCredentialCannotBeAddedToThePolicy() { + assertThatThrownBy(() -> FingerprintHeaderPolicy.including(Set.of("Authorization"))) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> FingerprintHeaderPolicy.including(Set.of("traceparent"))) + .as("a per-exchange header makes a legitimate retry look like a different request") + .isInstanceOf(IllegalArgumentException.class); + assertThat(FingerprintHeaderPolicy.including(Set.of("X-Region")).contributes("x-region")) + .isTrue(); + } + + @Test + @DisplayName("an operation with no body still fingerprints") + void anOperationWithNoBodyStillFingerprints() { + assertThat(factory.create(TRANSFER, Map.of("id", "1"), null, Map.of()).hex()).isNotBlank(); + } + + @Test + @DisplayName("the factory produces the application module's fingerprint type") + void theFactoryProducesTheApplicationFingerprintType() { + // application-core already owns the idempotency port, record and store contract. A second + // fingerprint type in the transport would be a second answer to "is this the same request". + assertThat(fingerprint(Map.of(), new Transfer("acc-2", 10))) + .isInstanceOf(RequestFingerprint.class); + } + + private RequestFingerprint fingerprint(Map pathVariables, Object command) { + return factory.create(TRANSFER, pathVariables, command, Map.of()); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/idempotency/WebIdempotencyGateTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/idempotency/WebIdempotencyGateTest.java new file mode 100644 index 00000000..bd282501 --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/idempotency/WebIdempotencyGateTest.java @@ -0,0 +1,343 @@ +package dev.caskeleton.adapter.inbound.web.idempotency; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.inbound.web.core.WebOperationName; +import dev.caskeleton.adapter.inbound.web.json.WebObjectMapperFactory; +import dev.caskeleton.adapter.inbound.web.operation.IdempotencyPolicy; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Each case is a way a duplicate could get through, or a legitimate retry could be refused. + * + *

The four outcomes are not interchangeable, so the tests assert the outcome rather than a + * boolean: answering a fingerprint mismatch with a replay hands a caller a receipt for a request + * they never made, and that failure is invisible to a test that only asks "was it admitted". + */ +class WebIdempotencyGateTest { + + private static final WebOperationName TRANSFER = new WebOperationName("transfers.create"); + private static final Instant NOW = Instant.parse("2026-08-25T10:00:00Z"); + private static final Duration TTL = Duration.ofHours(24); + + record Transfer(String to, int amount) {} + + private final InMemoryIdempotencyStore store = new InMemoryIdempotencyStore(); + + private final WebIdempotencyGate gate = + new WebIdempotencyGate( + store, + new SemanticRequestFingerprintFactory( + new DeterministicCommandEncoder(WebObjectMapperFactory.standard()), + FingerprintHeaderPolicy.standard()), + Clock.fixed(NOW, ZoneOffset.UTC), + TTL); + + private IdempotencyAdmission admit(String key, Object command) { + return gate.admit( + IdempotencyPolicy.OPTIONAL, TRANSFER, "alice", null, key, Map.of(), command, Map.of()); + } + + @Test + @DisplayName("a first keyed request wins the claim") + void firstRequestProceeds() { + assertThat(admit("key-00000001", new Transfer("bob", 100)).outcome()) + .isEqualTo(IdempotencyAdmission.Outcome.PROCEED); + } + + @Test + @DisplayName("a repeat while the first attempt runs is in progress, not a replay") + void repeatWhileInFlightIsInProgress() { + admit("key-00000001", new Transfer("bob", 100)); + + assertThat(admit("key-00000001", new Transfer("bob", 100)).outcome()) + .isEqualTo(IdempotencyAdmission.Outcome.IN_PROGRESS); + } + + @Test + @DisplayName("a repeat after completion replays the stored response") + void repeatAfterCompletionReplays() { + admit("key-00000001", new Transfer("bob", 100)); + gate.complete(TRANSFER, "alice", null, "key-00000001", "{\"id\":\"t-1\"}"); + + IdempotencyAdmission admission = admit("key-00000001", new Transfer("bob", 100)); + + assertThat(admission.outcome()).isEqualTo(IdempotencyAdmission.Outcome.REPLAY); + assertThat(admission.record().orElseThrow().response().payload()).isEqualTo("{\"id\":\"t-1\"}"); + } + + @Test + @DisplayName("the same key with a different body is a mismatch, never a replay") + void reusedKeyWithDifferentBodyIsMismatch() { + admit("key-00000001", new Transfer("bob", 100)); + gate.complete(TRANSFER, "alice", null, "key-00000001", "{\"id\":\"t-1\"}"); + + // The dangerous outcome is REPLAY: it would answer a 900-unit transfer with the receipt for a + // 100-unit one, and the client would record the wrong amount as settled. + assertThat(admit("key-00000001", new Transfer("bob", 900)).outcome()) + .isEqualTo(IdempotencyAdmission.Outcome.FINGERPRINT_MISMATCH); + } + + @Test + @DisplayName("a reordered body is the same request") + void reorderedBodyStillMatches() { + admit("key-00000001", Map.of("to", "bob", "amount", 100)); + gate.complete(TRANSFER, "alice", null, "key-00000001", "{}"); + + // A raw-byte digest fails here, and failing here means a retry from a client library that + // serializes members in hash order is charged twice. + assertThat(admit("key-00000001", Map.of("amount", 100, "to", "bob")).outcome()) + .isEqualTo(IdempotencyAdmission.Outcome.REPLAY); + } + + @Test + @DisplayName("two principals with the same key do not collide") + void keysAreScopedToPrincipal() { + gate.admit( + IdempotencyPolicy.OPTIONAL, + TRANSFER, + "alice", + null, + "key-00000001", + Map.of(), + null, + Map.of()); + + assertThat( + gate.admit( + IdempotencyPolicy.OPTIONAL, + TRANSFER, + "bob", + null, + "key-00000001", + Map.of(), + null, + Map.of()) + .outcome()) + .isEqualTo(IdempotencyAdmission.Outcome.PROCEED); + } + + @Test + @DisplayName("two tenants with the same principal and key do not collide") + void keysAreScopedToTenant() { + gate.admit( + IdempotencyPolicy.OPTIONAL, + TRANSFER, + "alice", + "t-1", + "key-00000001", + Map.of(), + null, + Map.of()); + + assertThat( + gate.admit( + IdempotencyPolicy.OPTIONAL, + TRANSFER, + "alice", + "t-2", + "key-00000001", + Map.of(), + null, + Map.of()) + .outcome()) + .isEqualTo(IdempotencyAdmission.Outcome.PROCEED); + } + + @Test + @DisplayName("two operations with the same key do not collide") + void keysAreScopedToOperation() { + admit("key-00000001", null); + + assertThat( + gate.admit( + IdempotencyPolicy.OPTIONAL, + new WebOperationName("refunds.create"), + "alice", + null, + "key-00000001", + Map.of(), + null, + Map.of()) + .outcome()) + .isEqualTo(IdempotencyAdmission.Outcome.PROCEED); + } + + @Test + @DisplayName("an unkeyed optional request runs unguarded") + void unkeyedOptionalRequestRuns() { + assertThat(admit(null, null).outcome()).isEqualTo(IdempotencyAdmission.Outcome.NOT_KEYED); + assertThat(store.size()).isZero(); + } + + @Test + @DisplayName("a blank key is treated as no key at all") + void blankKeyIsNoKey() { + assertThat(admit(" ", null).outcome()).isEqualTo(IdempotencyAdmission.Outcome.NOT_KEYED); + } + + @Test + @DisplayName("a required key that is missing is refused before the application is entered") + void missingRequiredKeyIsRefused() { + assertThatThrownBy( + () -> + gate.admit( + IdempotencyPolicy.REQUIRED, + TRANSFER, + "alice", + null, + null, + Map.of(), + null, + Map.of())) + .isInstanceOf(WebIdempotencyGate.IdempotencyKeyRequiredException.class); + assertThat(store.size()).isZero(); + } + + @Test + @DisplayName("a key sent to an operation that forbids one is refused, not silently ignored") + void forbiddenKeyIsRefused() { + // Ignoring it is the tempting option and the wrong one: the client believes its retries are + // being deduplicated, and nothing in the response tells it otherwise. + assertThatThrownBy( + () -> + gate.admit( + IdempotencyPolicy.FORBIDDEN, + TRANSFER, + "alice", + null, + "key-00000001", + Map.of(), + null, + Map.of())) + .isInstanceOf(WebIdempotencyGate.IdempotencyKeyNotAcceptedException.class); + } + + @Test + @DisplayName("an anonymous principal cannot claim a key") + void anonymousPrincipalIsRefused() { + // A shared anonymous scope lets any caller replay another's stored response by guessing a key. + assertThatThrownBy( + () -> + gate.admit( + IdempotencyPolicy.OPTIONAL, + TRANSFER, + "", + null, + "key-00000001", + Map.of(), + null, + Map.of())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("authenticated principal"); + } + + @Test + @DisplayName("a discarded claim frees the key for a retry") + void discardFreesTheKey() { + admit("key-00000001", null); + + gate.discard(TRANSFER, "alice", null, "key-00000001"); + + assertThat(admit("key-00000001", null).outcome()) + .isEqualTo(IdempotencyAdmission.Outcome.PROCEED); + } + + @Test + @DisplayName("a record that expires between the failed claim and the read is reclaimed") + void expiryBetweenClaimAndReadIsReclaimed() { + InMemoryIdempotencyStore racing = + new InMemoryIdempotencyStore() { + private final AtomicInteger beginAttempts = new AtomicInteger(); + + @Override + public boolean tryBegin( + dev.caskeleton.application.idempotency.IdempotencyScope scope, + dev.caskeleton.application.idempotency.RequestFingerprint fingerprint, + Instant expiresAt) { + // Fails once — as if another attempt held the key — then the record vanishes before it + // can be read. Without the second claim the caller would be told 409 forever. + return beginAttempts.getAndIncrement() != 0 + && super.tryBegin(scope, fingerprint, expiresAt); + } + }; + WebIdempotencyGate racingGate = + new WebIdempotencyGate( + racing, + new SemanticRequestFingerprintFactory( + new DeterministicCommandEncoder(WebObjectMapperFactory.standard()), + FingerprintHeaderPolicy.standard()), + Clock.fixed(NOW, ZoneOffset.UTC), + TTL); + + assertThat( + racingGate + .admit( + IdempotencyPolicy.OPTIONAL, + TRANSFER, + "alice", + null, + "key-00000001", + Map.of(), + null, + Map.of()) + .outcome()) + .isEqualTo(IdempotencyAdmission.Outcome.PROCEED); + } + + @Test + @DisplayName("only one of many concurrent attempts on a key proceeds") + void concurrentAttemptsElectOneWinner() throws Exception { + int attempts = 32; + CountDownLatch start = new CountDownLatch(1); + AtomicInteger proceeded = new AtomicInteger(); + try (ExecutorService pool = Executors.newFixedThreadPool(8)) { + Future[] futures = new Future[attempts]; + for (int i = 0; i < attempts; i++) { + futures[i] = + pool.submit( + () -> { + start.await(); + if (admit("key-race-0001", new Transfer("bob", 100)).shouldRun()) { + proceeded.incrementAndGet(); + } + return null; + }); + } + start.countDown(); + for (Future future : futures) { + future.get(30, TimeUnit.SECONDS); + } + } + + assertThat(proceeded).hasValue(1); + } + + @Test + @DisplayName("a gate with no record lifetime is refused at construction") + void zeroLifetimeIsRefused() { + assertThatThrownBy( + () -> + new WebIdempotencyGate( + store, + new SemanticRequestFingerprintFactory( + new DeterministicCommandEncoder(WebObjectMapperFactory.standard()), + FingerprintHeaderPolicy.standard()), + Clock.fixed(NOW, ZoneOffset.UTC), + Duration.ZERO)) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/json/WebObjectMapperFactoryTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/json/WebObjectMapperFactoryTest.java new file mode 100644 index 00000000..bbef1d7f --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/json/WebObjectMapperFactoryTest.java @@ -0,0 +1,133 @@ +package dev.caskeleton.adapter.inbound.web.json; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Instant; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import tools.jackson.databind.ObjectMapper; + +/** + * Every lenient Jackson default is a way for two parties to disagree about what a document meant. + * + *

Each case here is one of those disagreements, asserted from the rejecting side. A mapper test + * that only reads a well-formed document proves nothing about the mapper, because the default + * configuration reads that document too. + * + *

The mapper under test is a Jackson 3 ({@code tools.jackson}) one, because that is the type + * Spring Framework 7's message converter takes. An earlier version of this suite exercised a strict + * Jackson 2 mapper that was correct, green, and never consulted by the framework. + */ +class WebObjectMapperFactoryTest { + + private final ObjectMapper mapper = WebObjectMapperFactory.standard(); + + record CreateRequest(String title, int quantity) {} + + record Timestamped(Instant occurredAt) {} + + @Test + @DisplayName("a duplicate JSON key is refused") + void aDuplicateJsonKeyIsRefused() { + assertThatThrownBy( + () -> + mapper.readValue( + "{\"title\":\"a\",\"title\":\"b\",\"quantity\":1}", CreateRequest.class)) + .as("last-one-wins means the client believes the first one won") + .isInstanceOf(Exception.class); + } + + @Test + @DisplayName("an unknown property is refused") + void anUnknownPropertyIsRefused() { + assertThatThrownBy( + () -> + mapper.readValue( + "{\"title\":\"a\",\"quantity\":1,\"titel\":\"typo\"}", CreateRequest.class)) + .as("a dropped typo is a request that succeeds without doing what was asked") + .isInstanceOf(Exception.class); + } + + @Test + @DisplayName("a trailing token is refused") + void aTrailingTokenIsRefused() { + assertThatThrownBy( + () -> + mapper.readValue( + "{\"title\":\"a\",\"quantity\":1} {\"title\":\"b\"}", CreateRequest.class)) + .as("a concatenated second document must not be silently ignored") + .isInstanceOf(Exception.class); + } + + @Test + @DisplayName("a string is not silently read as a number") + void aStringIsNotSilentlyReadAsANumber() { + assertThatThrownBy( + () -> mapper.readValue("{\"title\":\"a\",\"quantity\":\"5\"}", CreateRequest.class)) + .as("otherwise the same request means two things to two services") + .isInstanceOf(Exception.class); + } + + @Test + @DisplayName("an empty string is not silently read as null") + void anEmptyStringIsNotSilentlyReadAsNull() { + assertThatThrownBy( + () -> mapper.readValue("{\"title\":\"a\",\"quantity\":\"\"}", CreateRequest.class)) + .as("a null-pointer deep in the application instead of a validation error at the boundary") + .isInstanceOf(Exception.class); + } + + @Test + @DisplayName("nesting past the profile depth is refused by the parser, not after") + void nestingPastTheProfileDepthIsRefusedByTheParser() { + WebJsonProfile shallow = new WebJsonProfile(true, true, true, true, true, 4, 100, 1024); + ObjectMapper bounded = WebObjectMapperFactory.create(shallow); + String deep = "{\"a\":".repeat(16) + "1" + "}".repeat(16); + + assertThatThrownBy(() -> bounded.readTree(deep)) + .as("a depth limit applied to a parsed tree has already paid for the tree") + .isInstanceOf(Exception.class); + } + + @Test + @DisplayName("an instant round-trips as an RFC 3339 string, never an epoch decimal") + void anInstantRoundTripsAsAnRfc3339String() throws Exception { + String json = mapper.writeValueAsString(new Timestamped(Instant.parse("2026-08-13T10:15:30Z"))); + + assertThat(json) + .as("the wire type manifest fixes this; a mapper default would render 1786...") + .contains("2026-08-13T10:15:30Z"); + assertThat(mapper.readValue(json, Timestamped.class).occurredAt()) + .isEqualTo(Instant.parse("2026-08-13T10:15:30Z")); + } + + @Test + @DisplayName("a well-formed document under the profile is accepted") + void aWellFormedDocumentIsAccepted() { + assertThatCode(() -> mapper.readValue("{\"title\":\"a\",\"quantity\":1}", CreateRequest.class)) + .as("the strictness must not be the kind that refuses everything") + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("the tolerant profile accepts an added property and stays strict elsewhere") + void theTolerantProfileAcceptsAnAddedProperty() { + ObjectMapper tolerant = + WebObjectMapperFactory.create(WebJsonProfile.tolerantOfUnknownProperties()); + + assertThatCode( + () -> + tolerant.readValue( + "{\"title\":\"a\",\"quantity\":1,\"added\":true}", CreateRequest.class)) + .as("a provider evolving their callback is not a client mistake") + .doesNotThrowAnyException(); + assertThatThrownBy( + () -> + tolerant.readValue( + "{\"title\":\"a\",\"title\":\"b\",\"quantity\":1}", CreateRequest.class)) + .as("tolerance of added fields is not tolerance of ambiguity") + .isInstanceOf(Exception.class); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/moduleboundary/WebBuildModel.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/moduleboundary/WebBuildModel.java new file mode 100644 index 00000000..715702f5 --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/moduleboundary/WebBuildModel.java @@ -0,0 +1,263 @@ +package dev.caskeleton.adapter.inbound.web.moduleboundary; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.TreeMap; +import java.util.TreeSet; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +/** + * Reads a web platform source tree and reports where it disagrees with the declared module map. + * + *

This deliberately works on source text rather than on compiled classes. An import that a + * boundary forbids has usually been erased by the compiler — a constant is inlined, a type is only + * named in a signature that is not retained — so a bytecode scan would report a boundary as clean + * while the source a reviewer reads still crosses it. + * + *

It lives in the test source set on purpose. Scanning a checkout is build-time work: a running + * application has no source tree to scan, and a runtime scan would fail exactly in the packaged + * environments where nothing is wrong. + * + *

Every entry point refuses to report success on an empty scan. A boundary rule that passes + * because it found no files is the failure mode this whole model exists to prevent. + */ +public final class WebBuildModel { + + /** + * Import prefixes that make a module framework bound. + * + *

The design's constraint names Servlet, Spring MVC, Spring WebFlux and Reactor. The set here + * is wider on purpose: {@code jakarta} covers servlet and validation, {@code com.fasterxml} + * covers Jackson, {@code io.micrometer} covers observation. Each binds a module to a runtime just + * as firmly as Spring does, and a "core" module that can only be exercised with Jackson on the + * classpath is not the portable decision the split exists to protect. + */ + public static final Pattern FRAMEWORK_IMPORT = + Pattern.compile( + // `tools.jackson` is Jackson 3 and `com.fasterxml` is Jackson 2. Both are listed because + // this repository runs on Spring 7, whose message converters take Jackson 3 — so a CORE + // module could have imported a mapper without this detector noticing, which is a hole in + // exactly the check that is supposed to have none. + "^(org\\.springframework|jakarta|reactor|io\\.micrometer|com\\.fasterxml" + + "|tools\\.jackson|org\\.slf4j|io\\.swagger)\\."); + + private static final Pattern IMPORT_STATEMENT = + Pattern.compile("^\\s*import\\s+(?:static\\s+)?([\\w.]+)\\s*;", Pattern.MULTILINE); + + private static final Pattern PACKAGE_STATEMENT = + Pattern.compile("^\\s*package\\s+([\\w.]+)\\s*;", Pattern.MULTILINE); + + private WebBuildModel() {} + + /** Scans every source root the platform's module map governs. */ + public static WebSourceGraph scanPlatformSources() { + return scan(platformSourceRoots()); + } + + /** + * Scans a Java source root — the directory that directly contains the {@code dev} package folder. + * + * @throws IllegalStateException when the root holds no platform source at all + */ + public static WebSourceGraph scan(Path sourceRoot) { + return scan(List.of(sourceRoot)); + } + + /** + * Scans several Java source roots as one platform. + * + * @throws IllegalStateException when the roots hold no platform source at all + */ + public static WebSourceGraph scan(List sourceRoots) { + Map> edges = new TreeMap<>(); + Map> frameworkImports = new TreeMap<>(); + Set packages = new TreeSet<>(); + int fileCount = 0; + + List files = new ArrayList<>(); + for (Path sourceRoot : sourceRoots) { + files.addAll(javaFilesUnder(sourceRoot)); + } + for (Path file : files) { + String source = read(file); + String packageName = declaredPackage(source).orElse(null); + if (packageName == null || !WebModuleBoundary.insidePlatform(packageName)) { + continue; + } + fileCount++; + packages.add(packageName); + String moduleId = WebModuleBoundary.moduleIdForPackage(packageName).orElse(null); + if (moduleId == null) { + // An unregistered package still has to appear in `packages` so the rule can name it, but it + // owns no module identity and therefore contributes no edges. + continue; + } + edges.computeIfAbsent(moduleId, key -> new TreeSet<>()); + frameworkImports.computeIfAbsent(moduleId, key -> new TreeSet<>()); + + Matcher matcher = IMPORT_STATEMENT.matcher(source); + while (matcher.find()) { + String imported = matcher.group(1); + if (WebModuleBoundary.insidePlatform(imported)) { + importedModule(imported) + .filter(target -> !target.equals(moduleId)) + .ifPresent(target -> edges.get(moduleId).add(target)); + } else if (FRAMEWORK_IMPORT.matcher(imported).find()) { + frameworkImports.get(moduleId).add(imported); + } + } + } + + if (fileCount == 0) { + throw new IllegalStateException( + "no web platform source was found under " + + sourceRoots.stream().map(root -> root.toAbsolutePath().toString()).toList() + + "; a boundary rule must never pass by scanning nothing"); + } + return new WebSourceGraph(edges, frameworkImports, packages, fileCount); + } + + /** Packages that hold source but were never given a module identity. */ + public static List undeclaredPackages(WebSourceGraph graph) { + return graph.packages().stream() + .filter(packageName -> WebModuleBoundary.moduleIdForPackage(packageName).isEmpty()) + .sorted() + .toList(); + } + + /** Declared modules whose package holds no source, so the declaration describes nothing. */ + public static List declaredButAbsentModules(WebSourceGraph graph) { + List absent = new ArrayList<>(); + WebModuleBoundary.packagesById() + .forEach( + (moduleId, packageName) -> { + boolean present = + graph.packages().stream() + .anyMatch( + scanned -> + scanned.equals(packageName) || scanned.startsWith(packageName + ".")); + if (!present) { + absent.add(moduleId + " (" + packageName + ")"); + } + }); + absent.sort(String::compareTo); + return List.copyOf(absent); + } + + /** Imports that cross a module boundary the declaration does not allow. */ + public static List undeclaredEdges(WebSourceGraph graph) { + List violations = new ArrayList<>(); + graph + .moduleEdges() + .forEach( + (from, targets) -> + targets.stream() + .filter(to -> !WebModuleBoundary.edgeAllowed(from, to)) + .forEach(to -> violations.add(from + " -> " + to))); + violations.sort(String::compareTo); + return List.copyOf(violations); + } + + /** Framework imports found in modules declared {@link WebModulePurity#CORE}. */ + public static List frameworkImportsInCoreModules(WebSourceGraph graph) { + Set core = WebModuleBoundary.coreModuleIds(); + List violations = new ArrayList<>(); + graph + .frameworkImports() + .forEach( + (moduleId, imports) -> { + if (!core.contains(moduleId)) { + return; + } + imports.forEach(imported -> violations.add(moduleId + " imports " + imported)); + }); + violations.sort(String::compareTo); + return List.copyOf(violations); + } + + /** Every source root the module map governs. */ + public static List platformSourceRoots() { + return List.of(mainSourceRoot()); + } + + /** + * Locates this leaf's production source root. + * + * @throws IllegalStateException when it cannot be found, rather than returning a path that would + * scan to zero files + */ + public static Path mainSourceRoot() { + String packagePath = WebModuleBoundary.PACKAGE_ROOT.replace('.', '/'); + for (Path directory = Path.of("").toAbsolutePath(); + directory != null; + directory = directory.getParent()) { + Path candidate = directory.resolve("src").resolve("main").resolve("java"); + if (Files.isDirectory(candidate.resolve(packagePath))) { + return candidate; + } + } + throw new IllegalStateException( + "cannot locate src/main/java/" + + packagePath + + " from " + + Path.of("").toAbsolutePath() + + "; the module boundary rules have nothing to check"); + } + + private static Optional importedModule(String importedType) { + int lastDot = importedType.lastIndexOf('.'); + if (lastDot < 0) { + return Optional.empty(); + } + // A static import names a member, so peel qualifiers until one resolves to a declared module. + for (String candidate = importedType.substring(0, lastDot); + candidate.length() >= WebModuleBoundary.PACKAGE_ROOT.length(); + candidate = candidate.substring(0, Math.max(candidate.lastIndexOf('.'), 0))) { + Optional moduleId = WebModuleBoundary.moduleIdForPackage(candidate); + if (moduleId.isPresent()) { + return moduleId; + } + if (candidate.lastIndexOf('.') < 0) { + break; + } + } + return Optional.empty(); + } + + private static Optional declaredPackage(String source) { + Matcher matcher = PACKAGE_STATEMENT.matcher(source); + return matcher.find() ? Optional.of(matcher.group(1)) : Optional.empty(); + } + + private static List javaFilesUnder(Path root) { + if (!Files.isDirectory(root)) { + throw new IllegalStateException("source root does not exist: " + root.toAbsolutePath()); + } + try (Stream files = Files.walk(root)) { + return files + .filter(Files::isRegularFile) + .filter(path -> path.getFileName().toString().endsWith(".java")) + .sorted() + .toList(); + } catch (IOException ex) { + throw new UncheckedIOException(ex); + } + } + + private static String read(Path file) { + try { + return Files.readString(file); + } catch (IOException ex) { + throw new UncheckedIOException(ex); + } + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/moduleboundary/WebModuleBoundaryTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/moduleboundary/WebModuleBoundaryTest.java new file mode 100644 index 00000000..e3738615 --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/moduleboundary/WebModuleBoundaryTest.java @@ -0,0 +1,131 @@ +package dev.caskeleton.adapter.inbound.web.moduleboundary; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * The machine check behind the "bounded packages instead of Gradle leaves" decision. + * + *

The web design models this platform as 23 Gradle modules. This repository's registry owns the + * leaf list, so they are sub-packages of one leaf — and that substitution buys nothing unless the + * split is enforced. A Gradle dependency gate cannot see inside a leaf, so this test does what the + * gate cannot: it scans the real production tree and compares it against the declared module map. + * + *

The negative fixtures matter as much as the positive assertions. A boundary test that has + * never been shown to fail is indistinguishable from one that scans the wrong directory, so each + * rule gets a synthetic tree that must be rejected. + */ +class WebModuleBoundaryTest { + + private static final String ROOT = WebModuleBoundary.PACKAGE_ROOT; + + private static WebSourceGraph production; + + @BeforeAll + static void scanProductionTree() { + production = WebBuildModel.scanPlatformSources(); + } + + @Test + @DisplayName("the scan actually reads the production tree") + void theScanActuallyReadsTheProductionTree() { + assertThat(production.fileCount()) + .as("the leaf holds over a hundred files; a scan far below that is reading the wrong tree") + .isGreaterThan(100); + assertThat(production.packages()).contains(ROOT + ".error", ROOT + ".observability"); + } + + @Test + @DisplayName("every production package has a declared module identity") + void everyProductionPackageHasADeclaredModuleIdentity() { + assertThat(WebBuildModel.undeclaredPackages(production)) + .as("add the package to WebStableModule before shipping it") + .isEmpty(); + } + + @Test + @DisplayName("every declared module exists in the production tree") + void everyDeclaredModuleExistsInTheProductionTree() { + assertThat(WebBuildModel.declaredButAbsentModules(production)) + .as("a declared module with no source describes nothing and hides a rename") + .isEmpty(); + } + + @Test + @DisplayName("every cross-module import is a declared edge") + void everyCrossModuleImportIsADeclaredEdge() { + assertThat(WebBuildModel.undeclaredEdges(production)).isEmpty(); + } + + @Test + @DisplayName("core modules stay framework free") + void coreModulesStayFrameworkFree() { + assertThat(WebBuildModel.frameworkImportsInCoreModules(production)) + .as("a CORE module may not bind to Spring, Servlet, Reactor, Jackson or Micrometer") + .isEmpty(); + } + + @Test + @DisplayName("an undeclared cross-module import is rejected") + void anUndeclaredCrossModuleImportIsRejected(@TempDir Path tree) { + writeType(tree, ROOT + ".conditional", "LeakyEtags", ROOT + ".ratelimit.ClientIpResolver"); + + assertThat(WebBuildModel.undeclaredEdges(WebBuildModel.scan(tree))) + .containsExactly("conditional -> ratelimit"); + } + + @Test + @DisplayName("a framework import in a core module is rejected") + void aFrameworkImportInACoreModuleIsRejected(@TempDir Path tree) { + writeType(tree, ROOT + ".cursor", "LeakyCursor", "org.springframework.stereotype.Component"); + + assertThat(WebBuildModel.frameworkImportsInCoreModules(WebBuildModel.scan(tree))) + .containsExactly("cursor imports org.springframework.stereotype.Component"); + } + + @Test + @DisplayName("a package with no declared identity is rejected") + void aPackageWithNoDeclaredIdentityIsRejected(@TempDir Path tree) { + writeType(tree, ROOT + ".undeclared", "Stowaway", null); + + assertThat(WebBuildModel.undeclaredPackages(WebBuildModel.scan(tree))) + .containsExactly(ROOT + ".undeclared"); + } + + @Test + @DisplayName("a scan that finds nothing fails instead of passing") + void aScanThatFindsNothingFailsInsteadOfPassing(@TempDir Path tree) { + assertThatThrownBy(() -> WebBuildModel.scan(tree)) + .as("an empty scan is the way a boundary rule silently stops checking anything") + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("never pass by scanning nothing"); + } + + private static void writeType( + Path sourceRoot, String packageName, String typeName, String importedType) { + Path directory = sourceRoot.resolve(packageName.replace('.', '/')); + String body = + "package " + + packageName + + ";\n\n" + + (importedType == null ? "" : "import " + importedType + ";\n\n") + + "final class " + + typeName + + " {}\n"; + try { + Files.createDirectories(directory); + Files.writeString(directory.resolve(typeName + ".java"), body); + } catch (IOException unwritable) { + throw new UncheckedIOException(unwritable); + } + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/moduleboundary/WebSourceGraph.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/moduleboundary/WebSourceGraph.java new file mode 100644 index 00000000..e6ff0865 --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/moduleboundary/WebSourceGraph.java @@ -0,0 +1,30 @@ +package dev.caskeleton.adapter.inbound.web.moduleboundary; + +import java.util.Map; +import java.util.Set; + +/** + * What a scan of a web platform source tree actually found. + * + *

Everything here is observed, never declared: {@link WebStableModule} says what the module map + * is supposed to be, and this record says what the checkout is. The boundary rules are the + * comparison between the two. + * + * @param moduleEdges module identifier to the module identifiers it imports, self-edges excluded + * @param frameworkImports module identifier to the framework imports it uses, empty when pure + * @param packages every package that contained at least one Java file + * @param fileCount how many Java files were read, so a rule can refuse to pass on an empty scan + */ +public record WebSourceGraph( + Map> moduleEdges, + Map> frameworkImports, + Set packages, + int fileCount) { + + /** Canonicalises the collections so callers cannot mutate a scan result. */ + public WebSourceGraph { + moduleEdges = Map.copyOf(moduleEdges); + frameworkImports = Map.copyOf(frameworkImports); + packages = Set.copyOf(packages); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/mvc/autoconfigure/WebMvcPlatformAutoConfigurationTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/mvc/autoconfigure/WebMvcPlatformAutoConfigurationTest.java new file mode 100644 index 00000000..07b80135 --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/mvc/autoconfigure/WebMvcPlatformAutoConfigurationTest.java @@ -0,0 +1,112 @@ +package dev.caskeleton.adapter.inbound.web.mvc.autoconfigure; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.inbound.web.budget.WebBudgetCatalog; +import dev.caskeleton.adapter.inbound.web.budget.WebBudgetProfileName; +import dev.caskeleton.adapter.inbound.web.error.WebProblemFactory; +import dev.caskeleton.adapter.inbound.web.http.WebMethodPolicy; +import dev.caskeleton.adapter.inbound.web.http.WebUriPolicy; +import dev.caskeleton.adapter.inbound.web.mvc.filter.WebMvcEvidenceFilter; +import dev.caskeleton.adapter.inbound.web.mvc.filter.WebMvcRequestIdFilter; +import dev.caskeleton.adapter.inbound.web.operation.WebOperationCatalog; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.boot.test.context.runner.ReactiveWebApplicationContextRunner; +import org.springframework.boot.test.context.runner.WebApplicationContextRunner; + +/** + * The starter is on by default in a servlet application, absent everywhere else, and every bean it + * supplies can be replaced. + */ +class WebMvcPlatformAutoConfigurationTest { + + private final WebApplicationContextRunner servlet = + new WebApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(WebMvcPlatformAutoConfiguration.class)); + + @Test + @DisplayName("a servlet application gets the platform beans with no configuration") + void aServletApplicationGetsThePlatformBeans() { + servlet.run( + context -> + assertThat(context) + .hasNotFailed() + .hasSingleBean(WebProblemFactory.class) + .hasSingleBean(WebOperationCatalog.class) + .hasSingleBean(WebBudgetCatalog.class) + .hasSingleBean(WebUriPolicy.class) + .hasSingleBean(WebMethodPolicy.class) + .hasSingleBean(WebMvcRequestIdFilter.class) + .hasSingleBean(WebMvcEvidenceFilter.class)); + } + + @Test + @DisplayName("the standard budget profile is registered, so a route can resolve one") + void theStandardBudgetProfileIsRegistered() { + servlet.run( + context -> + assertThat( + context + .getBean(WebBudgetCatalog.class) + .require(WebBudgetProfileName.standard())) + .isNotNull()); + } + + @Test + @DisplayName("a reactive application gets none of the servlet wiring") + void aReactiveApplicationGetsNoneOfTheServletWiring() { + new ReactiveWebApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(WebMvcPlatformAutoConfiguration.class)) + .run( + context -> + assertThat(context) + .as("the two starters are mutually exclusive at the type level") + .doesNotHaveBean(WebMvcEvidenceFilter.class)); + } + + @Test + @DisplayName("a non-web application gets none of it either") + void aNonWebApplicationGetsNoneOfItEither() { + new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(WebMvcPlatformAutoConfiguration.class)) + .run(context -> assertThat(context).doesNotHaveBean(WebProblemFactory.class)); + } + + @Test + @DisplayName("the master switch turns everything off") + void theMasterSwitchTurnsEverythingOff() { + servlet + .withPropertyValues("backend.web.mvc.enabled=false") + .run( + context -> + assertThat(context) + .as("off must mean no beans, not beans that happen not to be called") + .doesNotHaveBean(WebProblemFactory.class) + .doesNotHaveBean(WebMvcEvidenceFilter.class)); + } + + @Test + @DisplayName("the request id is not trusted from a caller by default") + void theRequestIdIsNotTrustedByDefault() { + assertThat(WebMvcPlatformSettings.defaults().trustInboundRequestId()) + .as("a caller choosing its own request id can make two exchanges share one identity") + .isFalse(); + assertThat(WebMvcPlatformSettings.defaults().strictJson()).isTrue(); + assertThat(WebMvcPlatformSettings.defaults().enabled()).isTrue(); + } + + @Test + @DisplayName("an adopter bean replaces the platform default") + void anAdopterBeanReplacesThePlatformDefault() { + servlet + .withBean(WebUriPolicy.class, () -> WebUriPolicy.withMaxLength(512)) + .run( + context -> { + assertThat(context).hasSingleBean(WebUriPolicy.class); + assertThat(context.getBean(WebUriPolicy.class).canonicalize("/api/v1/x")).isNotNull(); + }); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/mvc/filter/WebMvcEvidenceFilterTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/mvc/filter/WebMvcEvidenceFilterTest.java new file mode 100644 index 00000000..0be7101f --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/mvc/filter/WebMvcEvidenceFilterTest.java @@ -0,0 +1,78 @@ +package dev.caskeleton.adapter.inbound.web.mvc.filter; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.inbound.web.evidence.WebApplicationEvidence; +import dev.caskeleton.adapter.inbound.web.evidence.WebExecutionEvidenceTracker; +import jakarta.servlet.DispatcherType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockFilterChain; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +/** + * One logical request, one tracker — including across the async redispatch. + * + *

The case this protects is the one the whole evidence model exists for: an async response whose + * first pass recorded a commit. A tracker created per dispatch throws that away, and the request is + * then reported as safe to retry. + */ +class WebMvcEvidenceFilterTest { + + private final WebMvcEvidenceFilter filter = new WebMvcEvidenceFilter(); + + @Test + @DisplayName("an async redispatch keeps the tracker the first pass created") + void anAsyncRedispatchKeepsOneEvidenceTracker() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + + filter.doFilter(request, response, new MockFilterChain()); + Object first = request.getAttribute(WebMvcEvidenceFilter.EVIDENCE_ATTRIBUTE); + ((WebExecutionEvidenceTracker) first).markApplicationCommitted(); + + request.setDispatcherType(DispatcherType.ASYNC); + filter.doFilter(request, response, new MockFilterChain()); + + assertThat(request.getAttribute(WebMvcEvidenceFilter.EVIDENCE_ATTRIBUTE)) + .as("a tracker per dispatch throws away the commit the first pass recorded") + .isSameAs(first); + assertThat(((WebExecutionEvidenceTracker) first).snapshot().applicationEvidence()) + .isEqualTo(WebApplicationEvidence.APPLICATION_COMMITTED); + } + + @Test + @DisplayName("the filter runs on async and error dispatches rather than skipping them") + void theFilterRunsOnAsyncAndErrorDispatches() { + assertThat(filter.getOrder()).isNegative(); + // OncePerRequestFilter skips both by default, which would leave the dispatch that actually + // writes the response with nothing to record into. + assertThat(new WebMvcEvidenceFilter(5).getOrder()).isEqualTo(5); + } + + @Test + @DisplayName("the filter never reads the request body") + void theFilterNeverReadsTheRequestBody() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setContent("{\"title\":\"a\"}".getBytes(java.nio.charset.StandardCharsets.UTF_8)); + + filter.doFilter(request, new MockHttpServletResponse(), new MockFilterChain()); + + assertThat(request.getInputStream().readAllBytes()) + .as("consuming the stream here would leave the controller nothing to bind from") + .isNotEmpty(); + } + + @Test + @DisplayName("requiring a tracker fails loudly when the filter is not installed") + void requiringATrackerFailsLoudlyWhenTheFilterIsNotInstalled() { + MockHttpServletRequest request = new MockHttpServletRequest(); + + assertThat(WebMvcEvidenceFilter.existingTracker(request)).isNull(); + assertThatThrownBy(() -> WebMvcEvidenceFilter.requireTracker(request)) + .as("a fresh tracker here would record evidence nothing else reads") + .isInstanceOf(IllegalStateException.class); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/mvc/filter/WebMvcRequestIdFilterTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/mvc/filter/WebMvcRequestIdFilterTest.java new file mode 100644 index 00000000..4810257d --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/mvc/filter/WebMvcRequestIdFilterTest.java @@ -0,0 +1,71 @@ +package dev.caskeleton.adapter.inbound.web.mvc.filter; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockFilterChain; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +/** Correlation identifiers reach a log line, so an unvalidated one is a log-injection primitive. */ +class WebMvcRequestIdFilterTest { + + @Test + @DisplayName("a caller cannot choose the request id by default") + void aCallerCannotChooseTheRequestIdByDefault() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader(WebMvcRequestIdFilter.REQUEST_ID_HEADER, "chosen-by-caller"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + new WebMvcRequestIdFilter().doFilter(request, response, new MockFilterChain()); + + assertThat(WebMvcRequestIdFilter.requestId(request).orElseThrow().value()) + .as("two exchanges sharing one identity is how an investigation reads the wrong request") + .isNotEqualTo("chosen-by-caller"); + assertThat(response.getHeader(WebMvcRequestIdFilter.REQUEST_ID_HEADER)).isNotBlank(); + } + + @Test + @DisplayName("a trusted deployment accepts only a safe request id") + void aTrustedDeploymentAcceptsOnlyASafeRequestId() throws Exception { + MockHttpServletRequest safe = new MockHttpServletRequest(); + safe.addHeader(WebMvcRequestIdFilter.REQUEST_ID_HEADER, "req-42"); + new WebMvcRequestIdFilter(true, 0) + .doFilter(safe, new MockHttpServletResponse(), new MockFilterChain()); + assertThat(WebMvcRequestIdFilter.requestId(safe).orElseThrow().value()).isEqualTo("req-42"); + + MockHttpServletRequest injected = new MockHttpServletRequest(); + injected.addHeader( + WebMvcRequestIdFilter.REQUEST_ID_HEADER, "req-1" + NEWLINE + "FATAL forged line"); + new WebMvcRequestIdFilter(true, 0) + .doFilter(injected, new MockHttpServletResponse(), new MockFilterChain()); + assertThat(WebMvcRequestIdFilter.requestId(injected).orElseThrow().value()) + .as( + "a newline in a correlation id splits one log line into two, the second written by the caller") + .doesNotContain("forged"); + } + + @Test + @DisplayName("a valid traceparent is propagated and a malformed one is replaced") + void aValidTraceparentIsPropagatedAndAMalformedOneIsReplaced() throws Exception { + MockHttpServletRequest valid = new MockHttpServletRequest(); + valid.addHeader( + WebMvcRequestIdFilter.TRACEPARENT_HEADER, + "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"); + new WebMvcRequestIdFilter() + .doFilter(valid, new MockHttpServletResponse(), new MockFilterChain()); + assertThat(WebMvcRequestIdFilter.traceId(valid).orElseThrow().value()) + .isEqualTo("0af7651916cd43dd8448eb211c80319c"); + + MockHttpServletRequest malformed = new MockHttpServletRequest(); + malformed.addHeader(WebMvcRequestIdFilter.TRACEPARENT_HEADER, "garbage"); + new WebMvcRequestIdFilter() + .doFilter(malformed, new MockHttpServletResponse(), new MockFilterChain()); + assertThat(WebMvcRequestIdFilter.traceId(malformed)) + .as("refusing the request would let an upstream misconfiguration take the API down") + .isPresent(); + } + + private static final String NEWLINE = "\n"; +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/mvc/idempotency/WebMvcIdempotentInvokerTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/mvc/idempotency/WebMvcIdempotentInvokerTest.java new file mode 100644 index 00000000..2636c7fb --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/mvc/idempotency/WebMvcIdempotentInvokerTest.java @@ -0,0 +1,288 @@ +package dev.caskeleton.adapter.inbound.web.mvc.idempotency; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.inbound.web.core.ActorContext; +import dev.caskeleton.adapter.inbound.web.core.ApiMajorVersion; +import dev.caskeleton.adapter.inbound.web.core.ExternalRequestContext; +import dev.caskeleton.adapter.inbound.web.core.TenantContext; +import dev.caskeleton.adapter.inbound.web.core.WebOperationName; +import dev.caskeleton.adapter.inbound.web.core.WebRequestContext; +import dev.caskeleton.adapter.inbound.web.core.WebRequestId; +import dev.caskeleton.adapter.inbound.web.core.WebTraceId; +import dev.caskeleton.adapter.inbound.web.error.WebProblem; +import dev.caskeleton.adapter.inbound.web.error.WebProblemFactory; +import dev.caskeleton.adapter.inbound.web.http.ApiHeaders; +import dev.caskeleton.adapter.inbound.web.idempotency.DeterministicCommandEncoder; +import dev.caskeleton.adapter.inbound.web.idempotency.FingerprintHeaderPolicy; +import dev.caskeleton.adapter.inbound.web.idempotency.IdempotencyResponsePlan; +import dev.caskeleton.adapter.inbound.web.idempotency.SemanticRequestFingerprintFactory; +import dev.caskeleton.adapter.inbound.web.idempotency.WebIdempotencyGate; +import dev.caskeleton.adapter.inbound.web.json.WebObjectMapperFactory; +import dev.caskeleton.adapter.inbound.web.operation.WebOperationProfile; +import dev.caskeleton.application.idempotency.IdempotencyRecord; +import dev.caskeleton.application.idempotency.IdempotencyScope; +import dev.caskeleton.application.idempotency.IdempotencyStatus; +import dev.caskeleton.application.idempotency.IdempotencyStorePort; +import dev.caskeleton.application.idempotency.RequestFingerprint; +import dev.caskeleton.application.idempotency.StoredResponse; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockHttpServletRequest; + +/** + * The servlet binding of the gate, asserted on what a client actually receives. + * + *

The gate's own tests prove the decision. These prove the wire consequences of it: the handler + * runs exactly once, a replay is labelled as one, and a collision is a problem document with the + * status its code declares rather than a bare status the client has to interpret. + */ +class WebMvcIdempotentInvokerTest { + + private static final Instant NOW = Instant.parse("2026-08-25T10:00:00Z"); + private static final String KEY = "key-00000001"; + private static final WebOperationProfile PROFILE = WebOperationProfile.create("transfers.create"); + + record Transfer(String to, int amount) {} + + record Receipt(String id) {} + + private final InMemoryStore store = new InMemoryStore(); + private final WebIdempotencyGate gate = + new WebIdempotencyGate( + store, + new SemanticRequestFingerprintFactory( + new DeterministicCommandEncoder(WebObjectMapperFactory.standard()), + FingerprintHeaderPolicy.standard()), + Clock.fixed(NOW, ZoneOffset.UTC), + Duration.ofHours(24)); + private final IdempotentResponseWriter writer = + new IdempotentResponseWriter( + WebObjectMapperFactory.standardJsonMapper(), WebProblemFactory.standard()); + private final WebMvcIdempotentInvoker invoker = new WebMvcIdempotentInvoker(gate, writer); + private final AtomicInteger handlerRuns = new AtomicInteger(); + + private ResponseEntity post(Object command) { + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/transfers"); + request.addHeader(ApiHeaders.IDEMPOTENCY_KEY, KEY); + return invoker.invoke( + request, + context(), + PROFILE, + Map.of(), + command, + () -> { + handlerRuns.incrementAndGet(); + return new Receipt("t-1"); + }, + 201); + } + + @Test + @DisplayName("a first request runs the handler and answers 201") + void firstRequestRuns() { + ResponseEntity response = post(new Transfer("bob", 100)); + + assertThat(response.getStatusCode().value()).isEqualTo(201); + assertThat(response.getBody()).isEqualTo("{\"id\":\"t-1\"}"); + assertThat(handlerRuns).hasValue(1); + } + + @Test + @DisplayName("a retry replays the stored response without running the handler again") + void retryReplays() { + post(new Transfer("bob", 100)); + + ResponseEntity replay = post(new Transfer("bob", 100)); + + assertThat(handlerRuns).hasValue(1); + assertThat(replay.getStatusCode().value()).isEqualTo(201); + assertThat(replay.getBody()).isEqualTo("{\"id\":\"t-1\"}"); + } + + @Test + @DisplayName("a replay says so, so a client can tell a repeat from a second create") + void replayIsLabelled() { + post(new Transfer("bob", 100)); + + assertThat( + post(new Transfer("bob", 100)) + .getHeaders() + .getFirst(IdempotencyResponsePlan.REPLAYED_HEADER)) + .isEqualTo("true"); + } + + @Test + @DisplayName("a fresh response is not labelled as a replay") + void freshResponseIsNotLabelled() { + assertThat( + post(new Transfer("bob", 100)) + .getHeaders() + .containsHeader(IdempotencyResponsePlan.REPLAYED_HEADER)) + .isFalse(); + } + + @Test + @DisplayName("a reused key with a different body is 422 and does not run the handler") + void reusedKeyIsUnprocessable() { + post(new Transfer("bob", 100)); + + ResponseEntity mismatch = post(new Transfer("bob", 900)); + + assertThat(handlerRuns).hasValue(1); + assertThat(mismatch.getStatusCode().value()).isEqualTo(422); + assertThat(mismatch.getHeaders().getContentType()).hasToString(WebProblem.MEDIA_TYPE); + assertThat(mismatch.getBody()).contains("IDEMPOTENCY_KEY_REUSED"); + } + + @Test + @DisplayName( + "a retry that lands while the first attempt is still running is 409 with Retry-After") + void inFlightCollisionIsConflict() { + // The real window: the impatient retry arrives from inside the first handler, before it has + // returned and before anything has been stored. Pre-seeding a claim would not reproduce it — + // the fingerprint has to be the same one the first attempt claimed with. + MockHttpServletRequest first = new MockHttpServletRequest("POST", "/transfers"); + first.addHeader(ApiHeaders.IDEMPOTENCY_KEY, KEY); + ResponseEntity[] retry = newResponseSlot(); + + invoker.invoke( + first, + context(), + PROFILE, + Map.of(), + new Transfer("bob", 100), + () -> { + retry[0] = post(new Transfer("bob", 100)); + return new Receipt("t-1"); + }, + 201); + + assertThat(handlerRuns).hasValue(0); + assertThat(retry[0].getStatusCode().value()).isEqualTo(409); + assertThat(retry[0].getHeaders().getFirst(IdempotencyResponsePlan.RETRY_AFTER_HEADER)) + .isEqualTo("1"); + assertThat(retry[0].getBody()).contains("IDEMPOTENCY_REQUEST_IN_PROGRESS"); + } + + @Test + @DisplayName("a different body against an in-flight key is 422, not a retryable 409") + void inFlightKeyWithDifferentBodyIsUnprocessable() { + // The claim is held and unfinished, so 409 looks defensible — but no amount of waiting makes + // this request succeed under a key already spent on other content. 409 would invite a retry + // loop that can only ever end in the same answer. + store.tryBegin( + IdempotencyScope.of("alice", KEY, "transfers.create"), + new RequestFingerprint("0".repeat(64)), + NOW.plusSeconds(3600)); + + ResponseEntity answer = post(new Transfer("bob", 100)); + + assertThat(handlerRuns).hasValue(0); + assertThat(answer.getStatusCode().value()).isEqualTo(422); + } + + @SuppressWarnings("unchecked") + private static ResponseEntity[] newResponseSlot() { + return new ResponseEntity[1]; + } + + @Test + @DisplayName("a problem answer carries the request's trace id") + void problemCarriesTraceId() { + post(new Transfer("bob", 100)); + + assertThat(post(new Transfer("bob", 900)).getBody()) + .contains("0af7651916cd43dd8448eb211c80319c"); + } + + @Test + @DisplayName("a handler that throws leaves the claim in place") + void failedHandlerKeepsTheClaim() { + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/transfers"); + request.addHeader(ApiHeaders.IDEMPOTENCY_KEY, KEY); + + // The application may or may not have committed before it threw. Releasing the claim here + // would turn the client's retry into a second write; keeping it makes the retry a 409 the + // client can act on. + try { + invoker.invoke( + request, + context(), + PROFILE, + Map.of(), + new Transfer("bob", 100), + () -> { + throw new IllegalStateException("boom"); + }, + 201); + } catch (IllegalStateException expected) { + // the handler's failure propagates to the platform's exception handler + } + + assertThat(store.find(IdempotencyScope.of("alice", KEY, "transfers.create"), NOW)).isPresent(); + } + + private static WebRequestContext context() { + return new WebRequestContext( + new WebRequestId("req-1"), + new WebTraceId("0af7651916cd43dd8448eb211c80319c"), + new WebOperationName("transfers.create"), + new ApiMajorVersion(1), + ActorContext.authenticated("alice", java.util.Set.of()), + TenantContext.none(), + Locale.ENGLISH, + NOW, + NOW.plusSeconds(5), + new ExternalRequestContext("https", "api.example.com", 443, "")); + } + + private static final class InMemoryStore implements IdempotencyStorePort { + + private final Map records = new ConcurrentHashMap<>(); + + @Override + public boolean tryBegin( + IdempotencyScope scope, RequestFingerprint fingerprint, Instant expiresAt) { + return records.putIfAbsent( + scope, + new IdempotencyRecord( + scope, fingerprint, IdempotencyStatus.IN_FLIGHT, null, Instant.EPOCH, expiresAt)) + == null; + } + + @Override + public Optional find(IdempotencyScope scope, Instant now) { + return Optional.ofNullable(records.get(scope)).filter(record -> !record.isExpiredAt(now)); + } + + @Override + public void complete(IdempotencyScope scope, StoredResponse response) { + records.computeIfPresent( + scope, + (key, record) -> + new IdempotencyRecord( + record.scope(), + record.fingerprint(), + IdempotencyStatus.COMPLETED, + response, + record.createdAt(), + record.expiresAt())); + } + + @Override + public void discard(IdempotencyScope scope) { + records.remove(scope); + } + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/NotificationCallbackMvcControllerTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/NotificationCallbackMvcControllerTest.java index 29e2f463..be054fd5 100644 --- a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/NotificationCallbackMvcControllerTest.java +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/notification/platform/callback/NotificationCallbackMvcControllerTest.java @@ -260,61 +260,6 @@ class NotificationCallbackMvcControllerTest { } } - /** Never reached: attempt correlation happens only for an accepted callback. */ - private static final class UnusedAttemptResolver - implements dev.caskeleton.application.notification.platform.callback - .DeliveryAttemptResolverPort { - - @Override - public java.util.Optional< - dev.caskeleton.application.notification.platform.callback.DeliveryAttemptSnapshot> - byAttemptId(DeliveryAttemptId attemptId) { - return java.util.Optional.empty(); - } - - @Override - public java.util.Optional< - dev.caskeleton.application.notification.platform.callback.DeliveryAttemptSnapshot> - byProviderRequestId(ProviderProfileId profileId, String providerRequestIdHash) { - return java.util.Optional.empty(); - } - - @Override - public java.util.Optional< - dev.caskeleton.application.notification.platform.callback.DeliveryAttemptSnapshot> - byProviderRequestIdHash( - ProviderProfileId profileId, - dev.caskeleton.application.notification.platform.callback.ProviderRequestIdHash - providerRequestIdHash) { - return java.util.Optional.empty(); - } - } - - /** Never reached: projection runs only after a signature has been accepted. */ - private static final class UnusedProjectionStore - implements dev.caskeleton.application.notification.platform.callback - .DeliveryProjectionStorePort { - - @Override - public dev.caskeleton.application.notification.platform.callback.DeliveryProjection load( - DeliveryAttemptId attemptId) { - throw new UnsupportedOperationException(); - } - - @Override - public void save( - DeliveryAttemptId attemptId, - dev.caskeleton.application.notification.platform.callback.DeliveryProjection projection) { - throw new UnsupportedOperationException(); - } - - @Override - public boolean claimSuppressionSideEffect( - dev.caskeleton.application.notification.platform.api.DeliveryAttemptId attemptId) { - return true; - } - } - /** Never reached: nothing in this fixture gets as far as a transaction. */ private static final class UnusedTransactions implements dev.caskeleton.application.transaction.TransactionPort { diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/observability/WebObservabilityPolicyTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/observability/WebObservabilityPolicyTest.java new file mode 100644 index 00000000..b6d0c36a --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/observability/WebObservabilityPolicyTest.java @@ -0,0 +1,188 @@ +package dev.caskeleton.adapter.inbound.web.observability; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; +import java.time.Instant; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Ways telemetry becomes expensive or becomes a leak. + * + *

Neither failure produces an error. A high-cardinality tag shows up as a monitoring bill and + * then as a backend dropping series, at which point the alert that should have fired does not; a + * secret in a tag shows up as an unencrypted export of it to whichever SaaS the dashboards live in. + * Both are only catchable before the fact. + */ +class WebObservabilityPolicyTest { + + private final WebMetricCardinalityPolicy policy = WebMetricCardinalityPolicy.standard(); + + @Test + @DisplayName("high-cardinality and secret-bearing tags are refused") + void rejectsHighCardinalityAndSecrets() { + assertThat(policy.allowed("userId")).isFalse(); + assertThat(policy.allowed("http.url")).isFalse(); + assertThat(policy.allowed("idempotencyKey")).isFalse(); + assertThat(policy.allowed("operationName")).isTrue(); + } + + @Test + @DisplayName("the raw tenant identifier is not a tag") + void tenantIdentifierIsNotATag() { + // Bounded in principle and unbounded in practice, and it is the identifier a customer would + // least expect to find in a vendor's dashboard. + assertThat(policy.allowed("tenantId")).isFalse(); + assertThat(policy.allowed("tenant")).isFalse(); + } + + @Test + @DisplayName("resource identifiers and paths are not tags") + void resourceIdentifiersAreNotTags() { + assertThat(policy.allowed("resourceId")).isFalse(); + assertThat(policy.allowed("orderId")).isFalse(); + assertThat(policy.allowed("http.path")).isFalse(); + assertThat(policy.allowed("queryString")).isFalse(); + } + + @Test + @DisplayName("credentials are not tags") + void credentialsAreNotTags() { + assertThat(policy.allowed("token")).isFalse(); + assertThat(policy.allowed("cookie")).isFalse(); + assertThat(policy.allowed("authorization")).isFalse(); + } + + @Test + @DisplayName("the allowlist is exactly the eight bounded dimensions") + void allowlistIsClosed() { + // Pinned so that adding a tag is a deliberate edit here rather than something that happens in + // a call site nobody reviews for cardinality. + assertThat(policy.allowedTags()) + .containsExactlyInAnyOrder( + "routeTemplate", + "http.method", + "http.status", + "outcome", + "apiVersion", + "operationName", + "problemCode", + "clientProfile"); + } + + @Test + @DisplayName("a forbidden tag is refused at the point it is recorded") + void forbiddenTagIsRefusedWhenRecorded() { + // At construction, not on review. A tag checked after the map was built has already been + // written by whoever built it. + assertThatThrownBy(() -> WebObservationConvention.standard().tag("userId", "alice")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("allowlist"); + } + + @Test + @DisplayName("an allowed tag is recorded") + void allowedTagIsRecorded() { + Map tags = + WebObservationConvention.standard() + .tag("http.method", "GET") + .tag("http.status", "200") + .tag("operationName", "orders.list") + .tags(); + + assertThat(tags) + .containsEntry("http.method", "GET") + .containsEntry("operationName", "orders.list"); + } + + @Test + @DisplayName("a resolved path passed as a route template is refused") + void resolvedPathIsRefusedAsRouteTemplate() { + // The mistake that actually happens: the resolved path is what is easiest to reach for, and it + // produces one time series per resource. + assertThatThrownBy( + () -> WebObservationConvention.standard().routeTemplate("/api/v1/orders/40182")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("one series per resource"); + + assertThatCode( + () -> WebObservationConvention.standard().routeTemplate("/api/v1/orders/{orderId}")) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("an access log line records a template, never a URL with a query") + void accessLogRefusesAQueryString() { + // The most common way personal data reaches an access log: a search term or an email address + // that a client put in a query parameter. + assertThatThrownBy(() -> accessLogEvent("/api/v1/orders?q=alice%40example.com")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("query string"); + } + + @Test + @DisplayName("an audit event must name who, what and why") + void auditEventRequiresActorResourceAndReason() { + assertThatThrownBy(() -> auditEvent("", "order-1", "duplicate charge")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("who did this"); + assertThatThrownBy(() -> auditEvent("operator-1", "", "duplicate charge")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> auditEvent("operator-1", "order-1", " ")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("cannot be evaluated later"); + } + + @Test + @DisplayName("a complete audit event is accepted") + void completeAuditEventIsAccepted() { + assertThatCode(() -> auditEvent("operator-1", "order-1", "duplicate charge, ticket OPS-42")) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("every audited action is an override of a control, not merely a severe one") + void auditedActionsAreOverrides() { + // The membership test for this enum. Severity is not it: a failed payment is severe and is not + // audited, because nobody overrode anything. + assertThat(WebAuditAction.values()) + .containsExactlyInAnyOrder( + WebAuditAction.ADMIN_FORCE_DELETE, + WebAuditAction.ADMIN_REDRIVE, + WebAuditAction.PERMISSION_CHANGE, + WebAuditAction.SUNSET_CHANGE, + WebAuditAction.IDEMPOTENCY_OVERRIDE); + } + + private static WebAccessLogEvent accessLogEvent(String routeTemplate) { + return new WebAccessLogEvent( + "req-1", + "0af7651916cd43dd8448eb211c80319c", + "GET", + routeTemplate, + 200, + null, + "orders.list", + 1, + "browser", + Duration.ofMillis(12), + 0, + 512, + Instant.parse("2026-08-25T10:00:00Z")); + } + + private static WebAuditEvent auditEvent(String actor, String resource, String reason) { + return new WebAuditEvent( + WebAuditAction.IDEMPOTENCY_OVERRIDE, + actor, + resource, + reason, + "0af7651916cd43dd8448eb211c80319c", + Instant.parse("2026-08-25T10:00:00Z"), + Map.of()); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/openapi/WebOpenApiReleaseGateTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/openapi/WebOpenApiReleaseGateTest.java new file mode 100644 index 00000000..a71b8972 --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/openapi/WebOpenApiReleaseGateTest.java @@ -0,0 +1,166 @@ +package dev.caskeleton.adapter.inbound.web.openapi; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.Operation; +import io.swagger.v3.oas.models.PathItem; +import io.swagger.v3.oas.models.Paths; +import io.swagger.v3.oas.models.media.ObjectSchema; +import io.swagger.v3.oas.models.media.Schema; +import io.swagger.v3.oas.models.media.StringSchema; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * The asymmetry is the rule and it is easy to get backwards. + * + *

Removing a response field breaks a reader; adding a required request field breaks a sender. A + * diff that treats "added" or "removed" as one category waves one of them through, so both + * directions are asserted. + */ +class WebOpenApiReleaseGateTest { + + private final WebOpenApiBreakingPolicy policy = new WebOpenApiBreakingPolicy(); + private final WebOpenApiReleaseGate gate = new WebOpenApiReleaseGate(); + + @Test + @DisplayName("removing a response field is breaking") + void removingAResponseFieldIsBreaking() { + OpenAPI released = documentWith(schema("Order", List.of("id", "total"), List.of("id"))); + OpenAPI current = documentWith(schema("Order", List.of("id"), List.of("id"))); + + WebOpenApiDiffResult diff = policy.diff(released, current); + + assertThat(diff.breaking()).containsExactly("property removed: Order.total"); + assertThat(diff.hasBreaking()).isTrue(); + } + + @Test + @DisplayName("adding an optional field is additive") + void addingAnOptionalFieldIsAdditive() { + OpenAPI released = documentWith(schema("Order", List.of("id"), List.of("id"))); + OpenAPI current = documentWith(schema("Order", List.of("id", "note"), List.of("id"))); + + WebOpenApiDiffResult diff = policy.diff(released, current); + + assertThat(diff.hasBreaking()).isFalse(); + assertThat(diff.additive()).containsExactly("property added: Order.note"); + assertThat(diff.changed()).isTrue(); + } + + @Test + @DisplayName("making a field required is breaking") + void makingAFieldRequiredIsBreaking() { + OpenAPI released = documentWith(schema("Order", List.of("id", "note"), List.of("id"))); + OpenAPI current = documentWith(schema("Order", List.of("id", "note"), List.of("id", "note"))); + + assertThat(policy.diff(released, current).breaking()) + .as("a client that does not send it stops working") + .containsExactly("property became required: Order.note"); + } + + @Test + @DisplayName("removing an operation is breaking and adding one is not") + void removingAnOperationIsBreaking() { + OpenAPI released = + documentWith(schema("Order", List.of("id"), List.of()), "/api/v1/orders", "/api/v1/legacy"); + OpenAPI current = documentWith(schema("Order", List.of("id"), List.of()), "/api/v1/orders"); + + WebOpenApiDiffResult diff = policy.diff(released, current); + + assertThat(diff.breaking()).containsExactly("operation removed: GET /api/v1/legacy"); + assertThat(policy.diff(current, released).additive()) + .containsExactly("operation added: GET /api/v1/legacy"); + } + + @Test + @DisplayName("both narrowing and widening an enum are breaking") + void bothNarrowingAndWideningAnEnumAreBreaking() { + OpenAPI released = documentWith(enumSchema("Status", List.of("OPEN", "CLOSED"))); + + assertThat( + policy.diff(released, documentWith(enumSchema("Status", List.of("OPEN")))).breaking()) + .as("a client may already send the value being dropped") + .containsExactly("enum value removed: Status.CLOSED"); + assertThat( + policy + .diff( + released, documentWith(enumSchema("Status", List.of("OPEN", "CLOSED", "HELD")))) + .breaking()) + .as("a generated client deserialises into a sealed type and fails on an unknown value") + .containsExactly("enum value added: Status.HELD"); + } + + @Test + @DisplayName("a breaking change is blocked outside a major release and allowed inside one") + void aBreakingChangeIsBlockedOutsideAMajorRelease() { + OpenAPI released = documentWith(schema("Order", List.of("id", "total"), List.of())); + OpenAPI current = documentWith(schema("Order", List.of("id"), List.of())); + + assertThatThrownBy(() -> gate.evaluate(released, current, false)) + .isInstanceOf(WebOpenApiReleaseGate.OpenApiReleaseBlockedException.class) + .hasMessageContaining("Order.total"); + assertThatCode(() -> gate.evaluate(released, current, true)) + .as( + "a gate that refuses every breaking change gets bypassed, and a bypassed gate protects nothing") + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("an identical document produces no diff") + void anIdenticalDocumentProducesNoDiff() { + OpenAPI released = documentWith(schema("Order", List.of("id"), List.of("id"))); + + assertThat( + policy + .diff(released, documentWith(schema("Order", List.of("id"), List.of("id")))) + .changed()) + .isFalse(); + assertThat(WebOpenApiDiffResult.identical().changed()).isFalse(); + } + + @Test + @DisplayName("a document with no problem schema is refused") + void aDocumentWithNoProblemSchemaIsRefused() { + assertThatThrownBy( + () -> + gate.requireProblemInventoryMatches( + new OpenAPI(), + dev.caskeleton.adapter.inbound.web.error.ProblemCatalog.standard())) + .as("a client with no error contract has to guess") + .isInstanceOf(WebOpenApiReleaseGate.OpenApiReleaseBlockedException.class); + } + + private static Schema schema(String name, List properties, List required) { + ObjectSchema schema = new ObjectSchema(); + schema.setName(name); + properties.forEach(property -> schema.addProperty(property, new StringSchema())); + if (!required.isEmpty()) { + schema.setRequired(required); + } + return schema; + } + + private static Schema enumSchema(String name, List values) { + StringSchema schema = new StringSchema(); + schema.setName(name); + values.forEach(schema::addEnumItemObject); + return schema; + } + + private static OpenAPI documentWith(Schema schema, String... paths) { + OpenAPI api = new OpenAPI(); + api.setComponents(new Components().addSchemas(schema.getName(), schema)); + Paths documentPaths = new Paths(); + for (String path : paths.length == 0 ? new String[] {"/api/v1/orders"} : paths) { + documentPaths.addPathItem(path, new PathItem().get(new Operation())); + } + api.setPaths(documentPaths); + return api; + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/openapi/WebOpenApiSnapshotTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/openapi/WebOpenApiSnapshotTest.java new file mode 100644 index 00000000..167eb701 --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/openapi/WebOpenApiSnapshotTest.java @@ -0,0 +1,125 @@ +package dev.caskeleton.adapter.inbound.web.openapi; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.inbound.web.error.ProblemCatalog; +import dev.caskeleton.adapter.inbound.web.error.ProblemCode; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.media.Schema; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** The published document says what the runtime does, because both are generated from it. */ +class WebOpenApiSnapshotTest { + + private final OpenAPI document = WebOpenApiProfile.standard().generate(); + + @Test + @DisplayName("the document is 3.1.2 and publishes the problem schema") + void theDocumentIs312AndPublishesTheProblemSchema() { + assertThat(document.getOpenapi()) + .as("3.0 cannot express a string-encoded 64-bit integer or a nullable union") + .isEqualTo("3.1.2"); + assertThat(document.getComponents().getSchemas()).containsKey("Problem"); + assertThat(document.getComponents().getSchemas()).containsKey("ValidationIssue"); + assertThat(document.getComponents().getSchemas()).containsKey("CursorPage"); + } + + @Test + @DisplayName("every runtime problem code appears in the document") + void everyRuntimeProblemCodeAppearsInTheDocument() { + Schema codeSchema = property(document.getComponents().getSchemas().get("Problem"), "code"); + + List published = codeSchema.getEnum().stream().map(String::valueOf).sorted().toList(); + + assertThat(published) + .as("a code the document omits is a deserialisation failure in every generated client") + .containsExactlyElementsOf( + Arrays.stream(ProblemCode.values()).map(Enum::name).sorted().toList()); + } + + @Test + @DisplayName("the validation issue schema has no member for the submitted value") + void theValidationIssueSchemaHasNoRejectedValue() { + Schema issue = document.getComponents().getSchemas().get("ValidationIssue"); + + assertThat(propertyNames(issue)) + .as("echoing submitted content is how a password in the wrong field reaches a log") + .containsExactlyInAnyOrder("pointer", "code", "message"); + assertThat(issue.getAdditionalProperties()).isEqualTo(false); + } + + @Test + @DisplayName("the cursor is opaque in the document as well as on the wire") + void theCursorIsOpaqueInTheDocument() { + Schema page = document.getComponents().getSchemas().get("CursorPage"); + + assertThat(propertyNames(page)).contains("nextCursor"); + assertThat(property(page, "nextCursor").getProperties()) + .as("publishing its structure invites a client to construct one and skip the signature") + .isNull(); + } + + @Test + @DisplayName("the customizer adds the platform components without overwriting an existing one") + void theCustomizerAddsWithoutOverwriting() { + OpenAPI springdoc = new OpenAPI(); + springdoc.setComponents( + new io.swagger.v3.oas.models.Components() + .addSchemas("Problem", new io.swagger.v3.oas.models.media.StringSchema())); + + WebOpenApiCustomizer.standard().customise(springdoc); + + assertThat(springdoc.getOpenapi()).isEqualTo("3.1.2"); + assertThat(springdoc.getComponents().getSchemas().get("Problem")) + .as("a deployment that published its own Problem made a decision") + .isInstanceOf(io.swagger.v3.oas.models.media.StringSchema.class); + assertThat(springdoc.getComponents().getSchemas()).containsKey("CursorPage"); + assertThat(springdoc.getComponents().getSecuritySchemes()).containsKey("bearerAuth"); + } + + @Test + @DisplayName("the release gate refuses a document whose codes are not the runtime's") + void theReleaseGateRefusesADocumentWhoseCodesAreNotTheRuntimes() { + WebOpenApiReleaseGate gate = new WebOpenApiReleaseGate(); + + org.assertj.core.api.Assertions.assertThatCode( + () -> gate.requireProblemInventoryMatches(document, ProblemCatalog.standard())) + .doesNotThrowAnyException(); + + OpenAPI trimmed = WebOpenApiProfile.standard().generate(); + Schema trimmedCodes = property(trimmed.getComponents().getSchemas().get("Problem"), "code"); + trimmedCodes.getEnum().removeIf(value -> "RATE_LIMITED".equals(String.valueOf(value))); + + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> gate.requireProblemInventoryMatches(trimmed, ProblemCatalog.standard())) + .isInstanceOf(WebOpenApiReleaseGate.OpenApiReleaseBlockedException.class) + .hasMessageContaining("not the runtime's"); + } + + /** + * One property of a schema, typed. + * + *

{@code Schema.getProperties()} is a raw {@code Map} in io.swagger's model, so every access + * to it is an unchecked operation. Routing them all through one helper keeps the suppression to a + * single reviewed place instead of scattering it across the assertions. + */ + @SuppressWarnings("unchecked") + private static Schema property(Schema schema, String name) { + Map> properties = + (Map>) (Map) schema.getProperties(); + return properties.get(name); + } + + /** The property names of a schema, typed for the same reason. */ + @SuppressWarnings("unchecked") + private static Set propertyNames(Schema schema) { + Map> properties = + (Map>) (Map) schema.getProperties(); + return Set.copyOf(properties.keySet()); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/operation/WebOperationCatalogTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/operation/WebOperationCatalogTest.java new file mode 100644 index 00000000..93b8a0dc --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/operation/WebOperationCatalogTest.java @@ -0,0 +1,141 @@ +package dev.caskeleton.adapter.inbound.web.operation; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.inbound.web.budget.WebBudgetProfileName; +import dev.caskeleton.adapter.inbound.web.core.WebOperationName; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * The catalog refuses the registrations that would leave a route running on defaults nobody chose. + */ +class WebOperationCatalogTest { + + @Test + @DisplayName("a duplicate operation name is refused rather than overwritten") + void aDuplicateOperationNameIsRefused() { + InMemoryWebOperationCatalog catalog = new InMemoryWebOperationCatalog(); + WebOperationProfile profile = WebOperationProfile.readOnly("documents.get"); + catalog.register(profile); + + assertThatThrownBy(() -> catalog.register(profile)) + .as("the second registration would silently replace the reviewed one") + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("duplicate web operation"); + } + + @Test + @DisplayName("an unregistered operation is an error, not an empty lookup") + void anUnregisteredOperationIsAnError() { + InMemoryWebOperationCatalog catalog = new InMemoryWebOperationCatalog(); + + assertThatThrownBy(() -> catalog.require(new WebOperationName("documents.get"))) + .as("serving it with defaults is how a budget and an authorization profile stop existing") + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("unknown web operation"); + } + + @Test + @DisplayName("a registered operation is returned with its profile intact") + void aRegisteredOperationIsReturned() { + InMemoryWebOperationCatalog catalog = new InMemoryWebOperationCatalog(); + catalog.register(WebOperationProfile.readOnly("documents.get")); + catalog.register(WebOperationProfile.create("documents.create")); + + assertThat(catalog.require(new WebOperationName("documents.create")).idempotency()) + .isEqualTo(IdempotencyPolicy.REQUIRED); + assertThat(catalog.registeredNames()) + .containsExactlyInAnyOrder("documents.get", "documents.create"); + assertThat(catalog.size()).isEqualTo(2); + } + + @Test + @DisplayName("a read-only operation that demands an idempotency key is refused at construction") + void aReadOnlyOperationThatDemandsAnIdempotencyKeyIsRefused() { + assertThatThrownBy( + () -> + profile( + MutationKind.READ_ONLY, + HttpMethodSemantic.GET, + IdempotencyPolicy.REQUIRED, + PreconditionPolicy.NONE)) + .as("there is nothing to deduplicate, so the replay record would never be replayed") + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("read-only operation"); + + assertThatThrownBy( + () -> + profile( + MutationKind.READ_ONLY, + HttpMethodSemantic.GET, + IdempotencyPolicy.OPTIONAL, + PreconditionPolicy.NONE)) + .as("accepting the key is the same lie in a quieter voice") + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("a read-only operation that requires If-Match is refused") + void aReadOnlyOperationThatRequiresIfMatchIsRefused() { + assertThatThrownBy( + () -> + profile( + MutationKind.READ_ONLY, + HttpMethodSemantic.GET, + IdempotencyPolicy.FORBIDDEN, + PreconditionPolicy.REQUIRED)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("has no lost update"); + } + + @Test + @DisplayName("a mutation behind a safe method is refused") + void aMutationBehindASafeMethodIsRefused() { + assertThatThrownBy( + () -> + profile( + MutationKind.CREATE, + HttpMethodSemantic.GET, + IdempotencyPolicy.REQUIRED, + PreconditionPolicy.NONE)) + .as("a prefetching intermediary will replay a state change behind GET") + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("safe method"); + } + + @Test + @DisplayName("the coherent combinations are accepted") + void theCoherentCombinationsAreAccepted() { + assertThatCode( + () -> + profile( + MutationKind.PARTIAL_UPDATE, + HttpMethodSemantic.PATCH, + IdempotencyPolicy.OPTIONAL, + PreconditionPolicy.REQUIRED)) + .doesNotThrowAnyException(); + assertThat(WebOperationProfile.readOnly("documents.get").mutating()).isFalse(); + assertThat(WebOperationProfile.create("documents.create").mutating()).isTrue(); + } + + private static WebOperationProfile profile( + MutationKind kind, + HttpMethodSemantic method, + IdempotencyPolicy idempotency, + PreconditionPolicy precondition) { + return new WebOperationProfile( + new WebOperationName("documents.op"), + method, + kind, + WebBudgetProfileName.standard(), + AuthorizationProfileName.standard(), + idempotency, + precondition, + CachePolicyName.standard(), + AdmissionProfileName.standard(), + ResponseProfileName.standard()); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/operationasync/OperationAccessPolicyTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/operationasync/OperationAccessPolicyTest.java new file mode 100644 index 00000000..787e4a5f --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/operationasync/OperationAccessPolicyTest.java @@ -0,0 +1,122 @@ +package dev.caskeleton.adapter.inbound.web.operationasync; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.inbound.web.core.ActorContext; +import dev.caskeleton.adapter.inbound.web.core.ApiMajorVersion; +import dev.caskeleton.adapter.inbound.web.core.ExternalRequestContext; +import dev.caskeleton.adapter.inbound.web.core.TenantContext; +import dev.caskeleton.adapter.inbound.web.core.WebOperationName; +import dev.caskeleton.adapter.inbound.web.core.WebRequestContext; +import dev.caskeleton.adapter.inbound.web.core.WebRequestId; +import dev.caskeleton.adapter.inbound.web.core.WebTraceId; +import dev.caskeleton.application.operation.DurableOperation; +import dev.caskeleton.application.operation.DurableOperationId; +import dev.caskeleton.application.operation.DurableOperationState; +import java.time.Instant; +import java.util.Locale; +import java.util.Optional; +import java.util.Set; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Who can read somebody else's long-running work. + * + *

Each case is a caller who should be refused. An operation's polled document carries the result + * location and, when it failed, the problem detail, so a permissive check here leaks more than the + * fact that the work exists. + */ +class OperationAccessPolicyTest { + + private static final Instant NOW = Instant.parse("2026-08-25T10:00:00Z"); + + @Test + @DisplayName("the submitting principal may read it") + void ownerMayRead() { + assertThat(OperationAccessPolicy.mayAccess(operation("alice", null), context("alice", null))) + .isTrue(); + } + + @Test + @DisplayName("another principal may not") + void otherPrincipalMayNot() { + assertThat(OperationAccessPolicy.mayAccess(operation("alice", null), context("mallory", null))) + .isFalse(); + } + + @Test + @DisplayName("an anonymous caller may not, even with the right subject string") + void anonymousMayNot() { + // An unauthenticated context can still carry a subject — the header a caller supplied. Trusting + // it would let anyone read any operation by naming its owner. + WebRequestContext anonymous = + new WebRequestContext( + new WebRequestId("req-1"), + new WebTraceId("0af7651916cd43dd8448eb211c80319c"), + new WebOperationName("operations.get"), + new ApiMajorVersion(1), + ActorContext.anonymous(), + TenantContext.none(), + Locale.ENGLISH, + NOW, + NOW.plusSeconds(5), + new ExternalRequestContext("https", "api.example.com", 443, "")); + + assertThat(OperationAccessPolicy.mayAccess(operation("alice", null), anonymous)).isFalse(); + } + + @Test + @DisplayName("the same principal in another tenant may not") + void anotherTenantMayNot() { + // The subject matches. Only the tenant differs, which is exactly the cross-tenant read a + // principal-only check would allow. + assertThat(OperationAccessPolicy.mayAccess(operation("alice", "t-1"), context("alice", "t-2"))) + .isFalse(); + } + + @Test + @DisplayName("a tenant-scoped operation is not readable from an unscoped request") + void tenantScopedOperationNeedsATenant() { + assertThat(OperationAccessPolicy.mayAccess(operation("alice", "t-1"), context("alice", null))) + .isFalse(); + } + + @Test + @DisplayName("an unscoped operation is not readable from a tenant-scoped request") + void unscopedOperationIsNotReadableFromATenant() { + assertThat(OperationAccessPolicy.mayAccess(operation("alice", null), context("alice", "t-1"))) + .isFalse(); + } + + private static DurableOperation operation(String principal, String tenantId) { + return new DurableOperation( + new DurableOperationId("op-1"), + "reports.generate", + principal, + tenantId, + DurableOperationState.PENDING, + NOW, + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty(), + NOW.plusSeconds(3600)); + } + + private static WebRequestContext context(String subject, String tenantId) { + return new WebRequestContext( + new WebRequestId("req-1"), + new WebTraceId("0af7651916cd43dd8448eb211c80319c"), + new WebOperationName("operations.get"), + new ApiMajorVersion(1), + ActorContext.authenticated(subject, Set.of()), + tenantId == null ? TenantContext.none() : TenantContext.resolved(tenantId), + Locale.ENGLISH, + NOW, + NOW.plusSeconds(5), + new ExternalRequestContext("https", "api.example.com", 443, "")); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/operationasync/OperationResourceTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/operationasync/OperationResourceTest.java new file mode 100644 index 00000000..5ea653c8 --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/operationasync/OperationResourceTest.java @@ -0,0 +1,230 @@ +package dev.caskeleton.adapter.inbound.web.operationasync; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.inbound.web.error.ProblemCode; +import dev.caskeleton.adapter.inbound.web.error.WebProblemFactory; +import java.net.URI; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Each case is a state a client could be handed that it cannot act on. + * + *

A polled operation is read by software, not a person, so every incoherent combination becomes + * a client branch that silently does nothing. The constructor is where they are refused, and these + * are the ones worth refusing. + */ +class OperationResourceTest { + + private static final OperationId ID = new OperationId("op-1"); + private static final Instant CREATED = Instant.parse("2026-08-25T10:00:00Z"); + private static final Instant EXPIRES = CREATED.plus(Duration.ofDays(1)); + + @Test + @DisplayName("a succeeded operation must say where its result is") + void succeededOperationRequiresResultLocation() { + assertThatThrownBy(() -> OperationResource.succeeded(ID, CREATED, Optional.empty())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("where its result is"); + } + + @Test + @DisplayName("a succeeded operation with a result location is accepted") + void succeededOperationWithResultIsAccepted() { + OperationResource resource = + OperationResource.succeeded(ID, CREATED, Optional.of(URI.create("/results/op-1"))); + + assertThat(resource.status()).isEqualTo(OperationStatus.SUCCEEDED); + assertThat(resource.finished()).isTrue(); + } + + @Test + @DisplayName("a failed operation must carry a problem document") + void failedOperationRequiresProblem() { + assertThatThrownBy( + () -> + new OperationResource( + ID, + OperationStatus.FAILED, + CREATED, + Optional.of(CREATED), + Optional.of(CREATED), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty(), + EXPIRES)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("problem document"); + } + + @Test + @DisplayName("a problem document on a non-failed operation is refused") + void nonFailedOperationCannotCarryProblem() { + // A RUNNING operation carrying an error is the shape that makes a client show a failure for + // work that is still going. + assertThatThrownBy( + () -> + new OperationResource( + ID, + OperationStatus.RUNNING, + CREATED, + Optional.of(CREATED), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.of( + WebProblemFactory.standard() + .create( + ProblemCode.INTERNAL_ERROR, + "boom", + URI.create("/x"), + "0af7651916cd43dd8448eb211c80319c", + List.of())), + Optional.empty(), + EXPIRES)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("a terminal operation must say when it finished") + void terminalOperationRequiresCompletionTime() { + assertThatThrownBy( + () -> + new OperationResource( + ID, + OperationStatus.CANCELED, + CREATED, + Optional.of(CREATED), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty(), + EXPIRES)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("when it finished"); + } + + @Test + @DisplayName("an unfinished operation cannot claim a completion time") + void unfinishedOperationCannotHaveCompletionTime() { + assertThatThrownBy( + () -> + new OperationResource( + ID, + OperationStatus.RUNNING, + CREATED, + Optional.of(CREATED), + Optional.of(CREATED), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty(), + EXPIRES)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("a pending operation has not started and a running one has") + void startTimeMatchesTheStatus() { + assertThatCode(() -> OperationResource.pending(ID, CREATED, EXPIRES, Duration.ofSeconds(1))) + .doesNotThrowAnyException(); + assertThatThrownBy( + () -> + new OperationResource( + ID, + OperationStatus.PENDING, + CREATED, + Optional.of(CREATED), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty(), + EXPIRES)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy( + () -> + new OperationResource( + ID, + OperationStatus.RUNNING, + CREATED, + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty(), + EXPIRES)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("an operation cannot start before it was accepted") + void startCannotPrecedeCreation() { + assertThatThrownBy( + () -> + OperationResource.pending(ID, CREATED, EXPIRES, Duration.ofSeconds(1)) + .running(CREATED.minusSeconds(1), null)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("an operation that expires when it is created is refused") + void expiryMustBeAfterCreation() { + assertThatThrownBy(() -> OperationResource.pending(ID, CREATED, CREATED, Duration.ofSeconds(1))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("EXPIRED is terminal but is not a failure") + void expiredIsTerminalWithoutBeingAFailure() { + // The work may well have succeeded. Reporting it as FAILED would tell a client something + // untrue about the world rather than something true about the record. + assertThat(OperationStatus.EXPIRED.terminal()).isTrue(); + assertThatCode( + () -> + new OperationResource( + ID, + OperationStatus.EXPIRED, + CREATED, + Optional.empty(), + Optional.of(EXPIRES), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty(), + EXPIRES)) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("progress cannot exceed its own total") + void progressCannotExceedTotal() { + assertThatThrownBy(() -> new OperationProgress(11, Optional.of(10L), Optional.empty())) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("progress with no total reports no fraction rather than inventing one") + void progressWithoutTotalHasNoFraction() { + assertThat(OperationProgress.of(7).fraction()).isEmpty(); + assertThat(new OperationProgress(5, Optional.of(10L), Optional.empty()).fraction()) + .contains(0.5); + } + + @Test + @DisplayName("an operation id that would need escaping in a URL is refused") + void operationIdMustBeUrlSafe() { + assertThatThrownBy(() -> new OperationId("op 1/../etc")) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/pagination/CollectionQueryCatalogTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/pagination/CollectionQueryCatalogTest.java new file mode 100644 index 00000000..af94a221 --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/pagination/CollectionQueryCatalogTest.java @@ -0,0 +1,137 @@ +package dev.caskeleton.adapter.inbound.web.pagination; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.inbound.web.pagination.FilterFieldCatalog.FilterableField; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * A sort name that reaches a query is a fragment of that query. + * + *

Every rejection here keeps caller-supplied text out of a place where it would become query + * structure rather than a query value. + */ +class CollectionQueryCatalogTest { + + private final InMemoryCollectionQueryCatalog catalog = InMemoryCollectionQueryCatalog.standard(); + + @Test + @DisplayName("an unpublished sort field is refused") + void anUnpublishedSortFieldIsRefused() { + assertThatThrownBy(() -> catalog.sortFields().resolve("drop_table")) + .isInstanceOf(UnsupportedQueryVocabularyException.class); + assertThatThrownBy(() -> catalog.sortFields().resolve(null)) + .isInstanceOf(UnsupportedQueryVocabularyException.class); + } + + @Test + @DisplayName("the rejection never echoes the value the caller sent") + void theRejectionNeverEchoesTheValue() { + assertThatThrownBy(() -> catalog.sortFields().resolve("drop_table; --")) + .as("quoting it back is a reflected-content vector in an error body") + .hasMessageNotContaining("drop_table"); + } + + @Test + @DisplayName("a published field resolves to the internal descriptor the registration chose") + void aPublishedFieldResolvesToItsInternalDescriptor() { + SortField field = catalog.sortFields().resolve("id"); + + assertThat(field.internalName()) + .as("the internal name is chosen by the registration, never derived from the request") + .isEqualTo("id"); + assertThat(field.uniqueTieBreaker()).isTrue(); + assertThat(catalog.sortFields().published("id")).isTrue(); + assertThat(catalog.sortFields().published("secret")).isFalse(); + } + + @Test + @DisplayName("a catalog with no unique tie-breaker is refused") + void aCatalogWithNoUniqueTieBreakerIsRefused() { + assertThatThrownBy( + () -> + new InMemoryCollectionQueryCatalog( + SortFieldCatalog.of(new SortField("createdAt", "created_at", false)), + FilterFieldCatalog.of(), + FilterOperatorCatalog.standard(), + ProjectionProfileCatalog.of(Map.of("default", Set.of("id"))))) + .as( + "two rows equal on every sort key have no defined order, so a page boundary repeats or skips") + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("tie-breaker"); + } + + @Test + @DisplayName("an operator is allowed per field, not globally") + void anOperatorIsAllowedPerFieldNotGlobally() { + FilterFieldCatalog fields = + FilterFieldCatalog.of( + new FilterableField("status", "status", Set.of(FilterOperatorCatalog.EQUALS)), + new FilterableField( + "createdAt", + "created_at", + Set.of(FilterOperatorCatalog.GREATER_OR_EQUAL, FilterOperatorCatalog.LESS_THAN))); + + assertThatCode(() -> fields.resolve("status", FilterOperatorCatalog.EQUALS)) + .doesNotThrowAnyException(); + assertThatThrownBy(() -> fields.resolve("status", FilterOperatorCatalog.GREATER_THAN)) + .as("a field can be safe to compare and disastrous to range-scan") + .isInstanceOf(UnsupportedQueryVocabularyException.class); + assertThatThrownBy(() -> fields.resolve("secret", FilterOperatorCatalog.EQUALS)) + .isInstanceOf(UnsupportedQueryVocabularyException.class); + } + + @Test + @DisplayName("an unpublished operator and an empty operator set are refused") + void anUnpublishedOperatorIsRefused() { + assertThatThrownBy(() -> catalog.filterOperators().resolve("regex")) + .as("an operator with no index strategy is a table scan a caller can trigger at will") + .isInstanceOf(UnsupportedQueryVocabularyException.class); + assertThat(catalog.filterOperators().resolve("eq")).isEqualTo("eq"); + assertThatThrownBy(() -> FilterOperatorCatalog.of(Set.of())) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("a projection is a named profile, not a caller-composed field list") + void aProjectionIsANamedProfile() { + assertThat(catalog.projections().resolve("default")).containsExactly("id"); + assertThatThrownBy(() -> catalog.projections().resolve("id,secret")) + .as("a caller-composed projection makes field authorisation a per-request question") + .isInstanceOf(UnsupportedQueryVocabularyException.class); + assertThatThrownBy(() -> ProjectionProfileCatalog.of(Map.of("empty", Set.of()))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("a duplicate registration is refused") + void aDuplicateRegistrationIsRefused() { + assertThatThrownBy( + () -> + SortFieldCatalog.of( + new SortField("id", "id", true), new SortField("id", "other", true))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("the vocabulary fingerprint changes when the vocabulary does") + void theVocabularyFingerprintChangesWhenTheVocabularyDoes() { + InMemoryCollectionQueryCatalog wider = + new InMemoryCollectionQueryCatalog( + SortFieldCatalog.of( + new SortField("id", "id", true), new SortField("name", "name", false)), + FilterFieldCatalog.of(), + FilterOperatorCatalog.standard(), + ProjectionProfileCatalog.of(Map.of("default", Set.of("id")))); + + assertThat(wider.vocabularyFingerprint()) + .as( + "a cursor issued before a field was added must not page a differently shaped result set") + .isNotEqualTo(catalog.vocabularyFingerprint()); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/pagination/HmacWebCursorCodecTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/pagination/HmacWebCursorCodecTest.java new file mode 100644 index 00000000..22fc35f5 --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/pagination/HmacWebCursorCodecTest.java @@ -0,0 +1,170 @@ +package dev.caskeleton.adapter.inbound.web.pagination; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.LinkedHashMap; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Base64 is encoding, not protection. + * + *

Every case here is a cursor a caller could construct if one of the authenticated fields were + * left out of the MAC — most damagingly the filter fingerprint, whose absence lets a cursor minted + * under one filter page through rows the new filter was supposed to exclude. + */ +class HmacWebCursorCodecTest { + + private static final Instant NOW = Instant.parse("2026-08-13T00:00:00Z"); + private static final String PROFILE = "orders.list"; + private static final String FINGERPRINT = "status=OPEN"; + + private final HmacWebCursorCodec codec = + new HmacWebCursorCodec(keyRing("k1"), Clock.fixed(NOW, ZoneOffset.UTC)); + + @Test + @DisplayName("a cursor round-trips and keeps its position") + void aCursorRoundTrips() { + String cursor = codec.encode(payload(NOW)); + + WebCursorPayload decoded = codec.decode(cursor, PROFILE, FINGERPRINT); + + assertThat(decoded.sortValues()) + .containsExactly(Map.entry("createdAt", "2026-08-01"), Map.entry("id", "42")); + assertThat(decoded.queryProfile()).isEqualTo(PROFILE); + assertThat(decoded.version()).isEqualTo(WebCursorPayload.CURRENT_VERSION); + } + + @Test + @DisplayName("a tampered cursor is refused") + void aTamperedCursorIsRefused() { + String cursor = codec.encode(payload(NOW)); + String[] parts = cursor.split("\\.", -1); + String tamperedBody = + java.util.Base64.getUrlEncoder() + .withoutPadding() + .encodeToString( + new String( + java.util.Base64.getUrlDecoder().decode(parts[1]), StandardCharsets.UTF_8) + .replace("42", "99") + .getBytes(StandardCharsets.UTF_8)); + + assertThatThrownBy( + () -> + codec.decode(parts[0] + "." + tamperedBody + "." + parts[2], PROFILE, FINGERPRINT)) + .as("an edited base64 body is how continue-where-you-left-off becomes start-wherever-I-say") + .isInstanceOf(WebCursorException.class); + } + + @Test + @DisplayName("a cursor from another query profile is refused") + void aCursorFromAnotherQueryProfileIsRefused() { + String cursor = codec.encode(payload(NOW)); + + assertThatThrownBy(() -> codec.decode(cursor, "documents.list", FINGERPRINT)) + .isInstanceOf(WebCursorException.class); + } + + @Test + @DisplayName("a cursor minted under a different filter is refused") + void aCursorMintedUnderADifferentFilterIsRefused() { + String cursor = codec.encode(payload(NOW)); + + assertThatThrownBy(() -> codec.decode(cursor, PROFILE, "status=CLOSED")) + .as("otherwise the cursor pages through rows the new filter was supposed to exclude") + .isInstanceOf(WebCursorException.class); + } + + @Test + @DisplayName("a cursor signed by an unknown key is refused") + void aCursorSignedByAnUnknownKeyIsRefused() { + String foreign = + new HmacWebCursorCodec(keyRing("other"), Clock.fixed(NOW, ZoneOffset.UTC)) + .encode(payload(NOW)); + + assertThatThrownBy(() -> codec.decode(foreign, PROFILE, FINGERPRINT)) + .isInstanceOf(WebCursorException.class); + } + + @Test + @DisplayName("a retired key still verifies, so rotation does not break a scan mid-page") + void aRetiredKeyStillVerifies() { + // k1 keeps the material the original ring used; that is what makes this a rotation rather than + // a different ring that happens to reuse a name. + WebCursorKeyRing rotated = WebCursorKeyRing.of("k2", Map.of("k1", key('k'), "k2", key('2'))); + String oldCursor = codec.encode(payload(NOW)); + + HmacWebCursorCodec afterRotation = + new HmacWebCursorCodec(rotated, Clock.fixed(NOW, ZoneOffset.UTC)); + + assertThat(rotated.activeKeyId()).isEqualTo("k2"); + assertThatCode(() -> afterRotation.decode(oldCursor, PROFILE, FINGERPRINT)) + .as("replacing the key outright turns every held cursor into a refusal mid-scan") + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("an expired cursor is refused") + void anExpiredCursorIsRefused() { + String cursor = codec.encode(payload(NOW)); + HmacWebCursorCodec later = + new HmacWebCursorCodec(keyRing("k1"), Clock.fixed(NOW.plusSeconds(7200), ZoneOffset.UTC)); + + assertThatThrownBy(() -> later.decode(cursor, PROFILE, FINGERPRINT)) + .isInstanceOf(WebCursorException.class); + } + + @Test + @DisplayName("malformed input is refused without saying which check failed") + void malformedInputIsRefused() { + for (String bad : new String[] {"", "not-a-cursor", "a.b", "a.b.c.d", "k1.!!!.sig"}) { + assertThatThrownBy(() -> codec.decode(bad, PROFILE, FINGERPRINT)) + .as("naming the failed check tells a caller how to get closer") + .isInstanceOf(WebCursorException.class) + .hasMessage("the cursor is not valid for this query"); + } + assertThatThrownBy(() -> codec.decode(null, PROFILE, FINGERPRINT)) + .isInstanceOf(WebCursorException.class); + } + + @Test + @DisplayName("a short signing key is refused, and there is no default key") + void aShortSigningKeyIsRefused() { + assertThatThrownBy(() -> WebCursorKeyRing.of("k1", Map.of("k1", new byte[16]))) + .as("a key shorter than the MAC it produces adds no strength") + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> WebCursorKeyRing.of("absent", Map.of("k1", key('a')))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("a cursor with no position is refused at construction") + void aCursorWithNoPositionIsRefused() { + assertThatThrownBy(() -> WebCursorPayload.current(PROFILE, FINGERPRINT, Map.of(), NOW)) + .isInstanceOf(IllegalArgumentException.class); + } + + private static WebCursorPayload payload(Instant issuedAt) { + Map sortValues = new LinkedHashMap<>(); + sortValues.put("createdAt", "2026-08-01"); + sortValues.put("id", "42"); + return WebCursorPayload.current(PROFILE, FINGERPRINT, sortValues, issuedAt); + } + + private static WebCursorKeyRing keyRing(String keyId) { + return WebCursorKeyRing.of(keyId, Map.of(keyId, key(keyId.charAt(0)))); + } + + private static byte[] key(char seed) { + byte[] material = new byte[32]; + java.util.Arrays.fill(material, (byte) seed); + return material; + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/pagination/WebCollectionRequestParserTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/pagination/WebCollectionRequestParserTest.java new file mode 100644 index 00000000..194055a2 --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/pagination/WebCollectionRequestParserTest.java @@ -0,0 +1,156 @@ +package dev.caskeleton.adapter.inbound.web.pagination; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.inbound.web.pagination.FilterFieldCatalog.FilterableField; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * The parser is where caller text stops being caller text. + * + *

The order of its checks is the substance: the fingerprint is computed from the resolved query + * and the cursor is verified against that, so a cursor cannot be accepted before the platform knows + * which query it is being replayed into. + */ +class WebCollectionRequestParserTest { + + private static final Instant NOW = Instant.parse("2026-08-13T00:00:00Z"); + private static final String PROFILE = "orders.list"; + + private final InMemoryCollectionQueryCatalog catalog = + new InMemoryCollectionQueryCatalog( + SortFieldCatalog.of( + new SortField("id", "id", true), new SortField("createdAt", "created_at", false)), + FilterFieldCatalog.of( + new FilterableField("status", "status", Set.of(FilterOperatorCatalog.EQUALS))), + FilterOperatorCatalog.standard(), + ProjectionProfileCatalog.of(Map.of("default", Set.of("id")))); + + private final WebCursorCodec codec = + new HmacWebCursorCodec( + WebCursorKeyRing.of("k1", Map.of("k1", key())), Clock.fixed(NOW, ZoneOffset.UTC)); + + private final WebCollectionRequestParser parser = + new WebCollectionRequestParser(catalog, WebPageSizePolicy.standard(), codec); + + @Test + @DisplayName("the default page size is 50 and the ceiling is 200") + void theDefaultPageSizeIs50AndTheCeilingIs200() { + assertThat(parse(null, Map.of(), null, null).pageSize()).isEqualTo(50); + assertThat(parse(null, Map.of(), 200, null).pageSize()).isEqualTo(200); + assertThatThrownBy(() -> parse(null, Map.of(), 201, null)) + .as("clamping silently makes a client read a short page as the last one") + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> parse(null, Map.of(), 0, null)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("the sort resolves to an internal descriptor and a direction") + void theSortResolvesToAnInternalDescriptor() { + WebCollectionRequest ascending = parse("createdAt", Map.of(), null, null); + WebCollectionRequest descending = parse("-createdAt", Map.of(), null, null); + + assertThat(ascending.sort().internalName()).isEqualTo("created_at"); + assertThat(ascending.descending()).isFalse(); + assertThat(descending.descending()).isTrue(); + assertThatThrownBy(() -> parse("secret", Map.of(), null, null)) + .isInstanceOf(UnsupportedQueryVocabularyException.class); + } + + @Test + @DisplayName("a filter resolves through the catalog and never survives as caller text") + void aFilterResolvesThroughTheCatalog() { + WebCollectionRequest request = parse(null, Map.of("status.eq", "OPEN"), null, null); + + assertThat(request.filters()).containsExactly(Map.entry("status.eq", "OPEN")); + assertThatThrownBy(() -> parse(null, Map.of("secret.eq", "x"), null, null)) + .isInstanceOf(UnsupportedQueryVocabularyException.class); + assertThatThrownBy(() -> parse(null, Map.of("status.gt", "OPEN"), null, null)) + .as("the operator decides what the storage layer may do with the field") + .isInstanceOf(UnsupportedQueryVocabularyException.class); + } + + @Test + @DisplayName("a cursor from a different filter is refused by the parser") + void aCursorFromADifferentFilterIsRefused() { + WebCollectionRequest first = parse(null, Map.of("status.eq", "OPEN"), null, null); + String cursor = parser.nextCursor(first, lastRow(), NOW); + + assertThatCode(() -> parse(null, Map.of("status.eq", "OPEN"), null, cursor)) + .doesNotThrowAnyException(); + assertThatThrownBy(() -> parse(null, Map.of("status.eq", "CLOSED"), null, cursor)) + .as("a cursor replayed under a new filter pages through rows that filter excluded") + .isInstanceOf(WebCursorException.class); + } + + @Test + @DisplayName("a cursor from a different sort order is refused") + void aCursorFromADifferentSortOrderIsRefused() { + String cursor = parser.nextCursor(parse("createdAt", Map.of(), null, null), lastRow(), NOW); + + assertThatThrownBy(() -> parse("-createdAt", Map.of(), null, cursor)) + .isInstanceOf(WebCursorException.class); + } + + @Test + @DisplayName("the fingerprint does not depend on parameter order") + void theFingerprintDoesNotDependOnParameterOrder() { + Map oneOrder = new LinkedHashMap<>(); + oneOrder.put("status.eq", "OPEN"); + Map otherOrder = new LinkedHashMap<>(); + otherOrder.put("status.eq", "OPEN"); + + assertThat(parse(null, oneOrder, null, null).filterFingerprint()) + .as("?a=1&b=2 and ?b=2&a=1 are the same filter, so they must be the same page") + .isEqualTo(parse(null, otherOrder, null, null).filterFingerprint()); + } + + @Test + @DisplayName("a total count is refused unless the profile permits one") + void aTotalCountIsRefusedUnlessPermitted() { + assertThatThrownBy(() -> parser.parse(PROFILE, null, Map.of(), null, null, true, false)) + .as("counting is a second query over the whole filtered set") + .isInstanceOf(UnsupportedQueryVocabularyException.class); + assertThat(parser.parse(PROFILE, null, Map.of(), null, null, true, true).includeTotalCount()) + .isTrue(); + assertThat(parser.parse(PROFILE, null, Map.of(), null, null, false, true).includeTotalCount()) + .isFalse(); + } + + @Test + @DisplayName("a first page carries no cursor and a continuation does") + void aFirstPageCarriesNoCursor() { + WebCollectionRequest first = parse(null, Map.of(), null, null); + + assertThat(first.continuation()).isFalse(); + assertThat(parse(null, Map.of(), null, parser.nextCursor(first, lastRow(), NOW)).continuation()) + .isTrue(); + } + + private WebCollectionRequest parse( + String sort, Map filters, Integer pageSize, String cursor) { + return parser.parse(PROFILE, sort, filters, pageSize, cursor, false, false); + } + + private static Map lastRow() { + Map values = new LinkedHashMap<>(); + values.put("id", "42"); + return values; + } + + private static byte[] key() { + byte[] material = new byte[32]; + java.util.Arrays.fill(material, (byte) 'k'); + return material; + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/proxy/ForwardedHeaderSanitizerTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/proxy/ForwardedHeaderSanitizerTest.java new file mode 100644 index 00000000..bc4a4a26 --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/proxy/ForwardedHeaderSanitizerTest.java @@ -0,0 +1,129 @@ +package dev.caskeleton.adapter.inbound.web.proxy; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Forwarded headers are plain request headers, so anybody who can reach the port can set them. + * + *

Each case here is a thing a caller gets to choose if the trust check is missing: the address + * its rate limit is keyed on, the host a password-reset link points at, and whether the request + * "arrived over HTTPS". + */ +class ForwardedHeaderSanitizerTest { + + private final ForwardedHeaderSanitizer sanitizer = + new ForwardedHeaderSanitizer(TrustedProxyPolicy.of("10.0.0.0/8")); + + @Test + @DisplayName("forwarded headers from an untrusted peer are refused") + void forwardedHeadersFromAnUntrustedPeerAreRefused() { + assertThatThrownBy( + () -> sanitizer.normalize("203.0.113.10", Map.of("X-Forwarded-Host", "evil.example"))) + .as("ignoring silently leaves the attempt invisible and the same client keeps probing") + .isInstanceOf(UntrustedForwardedHeaderException.class) + .hasMessageContaining("203.0.113.10"); + } + + @Test + @DisplayName("the refusal message never echoes the header value") + void theRefusalMessageNeverEchoesTheHeaderValue() { + assertThatThrownBy( + () -> + sanitizer.normalize("203.0.113.10", Map.of("X-Forwarded-Host", "attacker-chosen"))) + .as("attacker-chosen content in the log line that reports the attack") + .hasMessageNotContaining("attacker-chosen"); + } + + @Test + @DisplayName("a trusted proxy's headers are believed and normalised") + void aTrustedProxysHeadersAreBelieved() { + NormalizedForwardedHeaders normalized = + sanitizer.normalize( + "10.1.2.3", + Map.of( + "X-Forwarded-Proto", "HTTPS", + "X-Forwarded-Host", "API.Example.com:443", + "X-Forwarded-Port", "443", + "X-Forwarded-Prefix", "/gateway/", + "X-Forwarded-For", "203.0.113.7, 10.1.2.3")); + + assertThat(normalized.scheme()).contains("https"); + assertThat(normalized.host()) + .as("the port is not part of the host") + .contains("api.example.com"); + assertThat(normalized.port()).contains(443); + assertThat(normalized.prefix()).as("a trailing slash is normalised away").contains("/gateway"); + assertThat(normalized.clientAddress()) + .as("the leftmost hop is the originating client") + .contains("203.0.113.7"); + } + + @Test + @DisplayName("a trusted proxy is trusted to be honest, not to be correct") + void aTrustedProxyIsTrustedToBeHonestNotCorrect() { + NormalizedForwardedHeaders normalized = + sanitizer.normalize( + "10.1.2.3", + Map.of( + "X-Forwarded-Proto", "gopher", + "X-Forwarded-Host", "", + "X-Forwarded-Port", "70000", + "X-Forwarded-Prefix", "/../admin")); + + assertThat(normalized.scheme()) + .as("only http and https are schemes this platform builds") + .isEmpty(); + assertThat(normalized.host()).as("an empty host would produce https:///reset").isEmpty(); + assertThat(normalized.port()).isEmpty(); + assertThat(normalized.prefix()) + .as("a traversing prefix chooses where a Location points") + .isEmpty(); + } + + @Test + @DisplayName("a direct-access deployment trusts nobody and reads its own socket") + void aDirectAccessDeploymentTrustsNobody() { + ForwardedHeaderSanitizer direct = + new ForwardedHeaderSanitizer(TrustedProxyPolicy.trustNobody(), false); + + assertThat(direct.normalize("10.1.2.3", Map.of("X-Forwarded-Host", "evil.example"))) + .isEqualTo(NormalizedForwardedHeaders.none()); + assertThat(TrustedProxyPolicy.trustNobody().trustsAnybody()).isFalse(); + } + + @Test + @DisplayName("a request with no forwarded headers is untouched") + void aRequestWithNoForwardedHeadersIsUntouched() { + assertThat(sanitizer.normalize("203.0.113.10", Map.of("Accept", "application/json"))) + .isEqualTo(NormalizedForwardedHeaders.none()); + assertThat(NormalizedForwardedHeaders.present(Map.of("Accept", "application/json"))).isFalse(); + assertThat(NormalizedForwardedHeaders.present(Map.of("forwarded", "for=1.2.3.4"))).isTrue(); + } + + @Test + @DisplayName("an address family never matches the other family's range") + void anAddressFamilyNeverMatchesTheOtherFamilysRange() { + TrustedProxyPolicy ipv4 = TrustedProxyPolicy.of("10.0.0.0/8"); + + assertThat(ipv4.isTrusted("10.255.255.254")).isTrue(); + assertThat(ipv4.isTrusted("11.0.0.1")).isFalse(); + assertThat(ipv4.isTrusted("::ffff:10.0.0.1")) + .as( + "the JDK canonicalises an IPv4-mapped address to Inet4Address, so this *is* 10.0.0.1 —" + + " and that is safe here because the peer address comes from the socket, not from a" + + " header a caller can choose") + .isTrue(); + assertThat(ipv4.isTrusted("2001:db8::1")) + .as("a genuine IPv6 peer has no business matching an IPv4 range") + .isFalse(); + assertThat(TrustedProxyPolicy.of("2001:db8::/32").isTrusted("10.0.0.1")).isFalse(); + assertThat(ipv4.isTrusted((String) null)).isFalse(); + assertThatThrownBy(() -> TrustedProxyPolicy.of("10.0.0.0")) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/security/WebCorsCsrfPolicyTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/security/WebCorsCsrfPolicyTest.java new file mode 100644 index 00000000..e8d4107c --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/security/WebCorsCsrfPolicyTest.java @@ -0,0 +1,176 @@ +package dev.caskeleton.adapter.inbound.web.security; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; +import java.util.Set; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Cross-origin and cross-site settings that look fine and are not. + * + *

Every case here describes a configuration under which the API answers correctly and the only + * difference is who else can make it answer. None of them would fail a test of the endpoint's own + * behaviour, which is why they are refused at construction instead. + */ +class WebCorsCsrfPolicyTest { + + private final WebCorsPolicyValidator validator = new WebCorsPolicyValidator(); + private final WebCsrfPolicyResolver csrf = new WebCsrfPolicyResolver(); + + @Test + @DisplayName("a wildcard origin with credentials is refused") + void rejectsWildcardOriginWithCredentials() { + assertThatThrownBy( + () -> + validator.validate( + new CorsProfile( + Set.of("*"), + Set.of("GET", "POST"), + Set.of("Content-Type"), + Set.of(), + true, + Duration.ofMinutes(10)))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("exact origin allowlist"); + } + + @Test + @DisplayName("a wildcard origin without credentials is fine") + void wildcardWithoutCredentialsIsAllowed() { + assertThatCode(() -> validator.validate(CorsProfile.publicReadOnly())) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("an origin carrying a path is refused") + void originWithPathIsRefused() { + // An origin is scheme, host and port. Adding a path produces an entry that never matches and + // reads as though the allowlist covers a route. + assertThatThrownBy( + () -> + validator.validate( + CorsProfile.credentialedFrontEnd(Set.of("https://app.example.com/admin")))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("no path"); + } + + @Test + @DisplayName("a bare host without a scheme is refused") + void bareHostIsRefused() { + assertThatThrownBy( + () -> validator.validate(CorsProfile.credentialedFrontEnd(Set.of("app.example.com")))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("scheme://host"); + } + + @Test + @DisplayName("a wildcard inside an origin is refused") + void wildcardInsideOriginIsRefused() { + // Reads as "any subdomain" and matches nothing at all, so the failure looks like the whole + // allowlist being ignored. + assertThatThrownBy( + () -> + validator.validate( + CorsProfile.credentialedFrontEnd(Set.of("https://*.example.com")))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("matches nothing"); + } + + @Test + @DisplayName("an uppercase origin is refused") + void uppercaseOriginIsRefused() { + assertThatThrownBy( + () -> + validator.validate( + CorsProfile.credentialedFrontEnd(Set.of("https://App.Example.com")))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("lowercase"); + } + + @Test + @DisplayName("origin matching is exact, not by suffix") + void originMatchingIsExact() { + CorsProfile profile = CorsProfile.credentialedFrontEnd(Set.of("https://app.example.com")); + + assertThat(profile.permits("https://app.example.com")).isTrue(); + // The two hosts a suffix check lets in. + assertThat(profile.permits("https://evil-app.example.com")).isFalse(); + assertThat(profile.permits("https://app.example.com.attacker.net")).isFalse(); + // And the scheme is part of it: http is a different origin from https. + assertThat(profile.permits("http://app.example.com")).isFalse(); + } + + @Test + @DisplayName("a credentialed profile may not accept wildcard headers") + void credentialedProfileCannotWildcardHeaders() { + assertThatThrownBy( + () -> + validator.validate( + new CorsProfile( + Set.of("https://app.example.com"), + Set.of("POST"), + Set.of("*"), + Set.of(), + true, + Duration.ofMinutes(10)))) + .isInstanceOf(IllegalStateException.class); + } + + @Test + @DisplayName("a session cookie requires CSRF protection") + void sessionCookieRequiresCsrf() { + assertThat(csrf.resolve(WebCredentialMode.SESSION_COOKIE).enabled()).isTrue(); + assertThat(csrf.resolve(WebCredentialMode.BFF_COOKIE).enabled()).isTrue(); + } + + @Test + @DisplayName("accepting both a cookie and a bearer token still requires CSRF") + void cookieAndBearerRequiresCsrf() { + // The case that gets reasoned away. The header path is safe and irrelevant: an attacker's + // cross-site form simply omits the header, the browser supplies the cookie, and the request + // authenticates. + assertThat(csrf.resolve(WebCredentialMode.COOKIE_AND_BEARER).enabled()).isTrue(); + } + + @Test + @DisplayName("a header-only credential is exempt, with the reason recorded") + void headerOnlyIsExemptWithAStatedReason() { + CsrfProfile profile = csrf.resolve(WebCredentialMode.AUTHORIZATION_HEADER_ONLY); + + assertThat(profile.enabled()).isFalse(); + // The reason is the point. "CSRF is off here" with no rationale is a line nobody dares change + // and nobody can justify a year later. + assertThat(profile.rationale()).contains("no cookie"); + } + + @Test + @DisplayName("a service-to-service credential is exempt, with the reason recorded") + void serviceToServiceIsExemptWithAStatedReason() { + CsrfProfile profile = csrf.resolve(WebCredentialMode.SERVICE_TO_SERVICE); + + assertThat(profile.enabled()).isFalse(); + assertThat(profile.rationale()).contains("not a browser"); + } + + @Test + @DisplayName("a CSRF decision with no stated reason is refused") + void csrfDecisionNeedsAReason() { + assertThatThrownBy(() -> new CsrfProfile(false, " ")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("cannot be reviewed"); + } + + @Test + @DisplayName("every credential mode is classified, so a new one cannot default to exempt") + void everyModeIsClassified() { + // A mode added to the enum without a decision here fails loudly rather than inheriting an + // exemption from a default branch. + for (WebCredentialMode mode : WebCredentialMode.values()) { + assertThat(csrf.resolve(mode).enabled()).isEqualTo(mode.ambientlyAttached()); + } + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/security/WebSecurityContextBridgeTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/security/WebSecurityContextBridgeTest.java new file mode 100644 index 00000000..80279ced --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/security/WebSecurityContextBridgeTest.java @@ -0,0 +1,106 @@ +package dev.caskeleton.adapter.inbound.web.security; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * A tenant a caller chose is a cross-tenant read waiting to happen. + * + *

The resolvers take only an authentication, so there is no argument through which a header + * could become a tenant. What these tests add is the other half: that the platform notices when a + * request tried anyway, rather than ignoring it and leaving the attempt invisible. + */ +class WebSecurityContextBridgeTest { + + private final WebSecurityContextBridge bridge = new WebSecurityContextBridge(); + + @Test + @DisplayName("a tenant header is refused and the authenticated tenant is the one used") + void aTenantHeaderIsRefusedAndTheAuthenticatedTenantIsUsed() { + AuthenticationView authentication = + AuthenticationView.authenticated("actor-1", Set.of("ROLE_USER"), "tenant-authenticated"); + + assertThat(bridge.resolve(authentication).tenant().value()).contains("tenant-authenticated"); + + assertThatThrownBy( + () -> bridge.resolve(authentication, Map.of("X-Tenant-Id", "tenant-attacker"))) + .as("ignoring it leaves a cross-tenant attempt invisible and the client keeps trying") + .isInstanceOf(UntrustedTenantInputException.class); + } + + @Test + @DisplayName("the refusal names no proposed value") + void theRefusalNamesNoProposedValue() { + assertThatThrownBy( + () -> + bridge.resolve(AuthenticationView.anonymous(), Map.of("tenant", "tenant-attacker"))) + .hasMessageNotContaining("tenant-attacker"); + } + + @Test + @DisplayName("every spelling of a tenant input is refused") + void everySpellingOfATenantInputIsRefused() { + WebTenantContextResolver resolver = new WebTenantContextResolver(); + + for (String name : new String[] {"X-Tenant-Id", "tenant-id", "TenantId", "TENANT"}) { + assertThatThrownBy(() -> resolver.rejectTenantInput(Map.of(name, "t"))) + .as("a case or hyphen variant is the same header to a proxy") + .isInstanceOf(UntrustedTenantInputException.class); + } + assertThatCode(() -> resolver.rejectTenantInput(Map.of("Accept", "application/json"))) + .doesNotThrowAnyException(); + assertThatCode(() -> resolver.rejectTenantInput(null)).doesNotThrowAnyException(); + } + + @Test + @DisplayName("an unauthenticated caller resolves to an anonymous actor with no tenant") + void anUnauthenticatedCallerResolvesToAnonymous() { + SecurityIdentity identity = bridge.resolve(AuthenticationView.anonymous()); + + assertThat(identity.actor().authenticated()).isFalse(); + assertThat(identity.actor().subject()).isEmpty(); + assertThat(identity.tenant().value()).isEmpty(); + } + + @Test + @DisplayName("an authenticated caller carries its subject and authorities") + void anAuthenticatedCallerCarriesItsSubjectAndAuthorities() { + SecurityIdentity identity = + bridge.resolve(AuthenticationView.authenticated("actor-1", Set.of("ROLE_ADMIN"))); + + assertThat(identity.actor().authenticated()).isTrue(); + assertThat(identity.actor().subject()).isEqualTo("actor-1"); + assertThat(identity.actor().authorities()).containsExactly("ROLE_ADMIN"); + assertThat(identity.tenant().value()) + .as("authentication with no tenant scope is not the same as tenant zero") + .isEmpty(); + } + + @Test + @DisplayName("an unauthenticated view cannot carry a subject") + void anUnauthenticatedViewCannotCarryASubject() { + assertThatThrownBy( + () -> + new AuthenticationView( + false, java.util.Optional.of("actor-1"), Set.of(), java.util.Optional.empty())) + .as("an unverified identifier that reaches an audit record looking verified") + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("the view has nowhere to put a token, so the web layer cannot verify one") + void theViewHasNowhereToPutAToken() { + // The design's rule is that the web layer does not re-implement token verification. The way it + // is held is structural: this record has no component that could hold a signature, a key or a + // claim set, so there is nothing here to verify with. + assertThat(AuthenticationView.class.getRecordComponents()) + .extracting("name") + .containsExactly("authenticated", "subject", "authorities", "tenantId"); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/testkit/arch/WebArchitectureRulesTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/testkit/arch/WebArchitectureRulesTest.java new file mode 100644 index 00000000..47f89bf5 --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/testkit/arch/WebArchitectureRulesTest.java @@ -0,0 +1,112 @@ +package dev.caskeleton.adapter.inbound.web.testkit.arch; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.core.importer.ClassFileImporter; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Each rule is shown failing against a fixture before it is trusted against production. + * + *

The production run lives at the composition root, which is the only place that sees every + * runtime leaf at once. What this suite proves is the other half: that the rules can fail at all. + */ +class WebArchitectureRulesTest { + + private static final JavaClasses BAD_CONTROLLER = + new ClassFileImporter() + .importPackages("dev.caskeleton.adapter.inbound.web.fixtures.badcontroller"); + + private static final JavaClasses PRODUCTION_WEB = + new ClassFileImporter() + .withImportOption(location -> !location.contains("/test/")) + .importPackages("dev.caskeleton.adapter.inbound.web"); + + @Test + @DisplayName("a controller holding a repository is rejected") + void aControllerHoldingARepositoryIsRejected() { + assertThatThrownBy( + () -> WebArchitectureRules.controllersAreUseCaseAdapters().check(BAD_CONTROLLER)) + .as("a controller with a repository has skipped the application entirely") + .isInstanceOf(AssertionError.class); + } + + @Test + @DisplayName("a transaction annotation on a controller is rejected at class and method level") + void aTransactionAnnotationOnAControllerIsRejected() { + // The condition is given the fixture annotation's name because the real ones are not on this + // leaf's classpath — a controller here cannot import a transaction annotation, which is the + // boundary working. The production rule runs against the real graph at the composition root. + var forbidden = + java.util.Set.of( + "dev.caskeleton.adapter.inbound.web.fixtures.badcontroller.FixtureTransactional"); + + assertThatThrownBy( + () -> + com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses() + .that() + .resideInAPackage("..fixtures.badcontroller..") + .should(new ForbiddenClassAnnotationCondition(forbidden)) + .check(BAD_CONTROLLER)) + .isInstanceOf(AssertionError.class); + + assertThatThrownBy( + () -> + com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noMethods() + .that() + .areDeclaredInClassesThat() + .resideInAPackage("..fixtures.badcontroller..") + .should(new ForbiddenMethodAnnotationCondition(forbidden)) + .check(BAD_CONTROLLER)) + .as("a class-level annotation covers every method, so both levels need a rule") + .isInstanceOf(AssertionError.class); + } + + @Test + @DisplayName("the production web tree already satisfies every rule") + void theProductionWebTreeSatisfiesEveryRule() { + assertThat(PRODUCTION_WEB.size()) + .as("a rule suite that imported nothing would pass on all counts") + .isGreaterThan(100); + + for (var rule : WebArchitectureRules.webScopedRules()) { + assertThatCode(() -> rule.check(PRODUCTION_WEB)) + .as("production violates: %s", rule.getDescription()) + .doesNotThrowAnyException(); + } + } + + @Test + @DisplayName("the rule set is complete and every rule belongs to exactly one scope") + void theRuleSetIsComplete() { + assertThat(WebArchitectureRules.all()).hasSize(7); + assertThat(WebArchitectureRules.webScopedRules()).hasSize(6); + assertThat(WebArchitectureRules.crossLeafRules()) + .as("the application-scoped rule runs where the application is visible, not here") + .hasSize(1); + } + + @Test + @DisplayName("the forbidden catalog matches by package family, not by exact name") + void theForbiddenCatalogMatchesByPackageFamily() { + assertThat( + WebForbiddenTypeCatalog.insideAny( + "org.springframework.data.jpa.repository", + WebForbiddenTypeCatalog.PERSISTENCE_AND_CLIENT_PACKAGES)) + .isTrue(); + assertThat( + WebForbiddenTypeCatalog.insideAny( + "org.springframework.dataflow", + WebForbiddenTypeCatalog.PERSISTENCE_AND_CLIENT_PACKAGES)) + .as("a prefix match without the dot would catch an unrelated package") + .isFalse(); + assertThat( + WebForbiddenTypeCatalog.insideAny( + null, WebForbiddenTypeCatalog.PERSISTENCE_AND_CLIENT_PACKAGES)) + .isFalse(); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/testkit/budget/TomcatWebBudgetIT.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/testkit/budget/TomcatWebBudgetIT.java new file mode 100644 index 00000000..3e5d8011 --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/testkit/budget/TomcatWebBudgetIT.java @@ -0,0 +1,30 @@ +package dev.caskeleton.adapter.inbound.web.testkit.budget; + +import dev.caskeleton.webtestkit.BudgetFixtureApplication; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.test.context.ActiveProfiles; + +/** + * Budget enforcement on the servlet default, over a real socket. + * + *

A real socket is the only place chunked transfer and a truncated response exist. MockMvc has + * neither, so it would report the two hardest cases in this contract as passing without having + * exercised them. + */ +@SpringBootTest( + classes = BudgetFixtureApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + // The budget handler is gated on this property. Turning it on here rather than defaulting it + // on keeps the production default off: a control that is on by default is one nobody notices. + properties = "backend.web.budgets.enabled=true") +@ActiveProfiles("web-contract") +class TomcatWebBudgetIT extends WebBudgetContract { + + @LocalServerPort private int port; + + @Override + protected HttpBudgetFixture fixture() { + return new HttpBudgetFixture(port); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/testkit/contract/CrossStackParityTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/testkit/contract/CrossStackParityTest.java new file mode 100644 index 00000000..5a6c1ba6 --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/testkit/contract/CrossStackParityTest.java @@ -0,0 +1,138 @@ +package dev.caskeleton.adapter.inbound.web.testkit.contract; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * The three transports must answer the same probe the same way. + * + *

Compared from recordings rather than in one process, because the stacks cannot coexist: Spring + * Boot deduces a single application type from the classpath, so Tomcat, Jetty and Reactor Netty + * each need their own source set. Each lane writes what it observed; this reads the files. + * + *

The indirection is also what makes the comparison mean something. A parity check that built + * both stacks from one test would be tempted into a shared helper, and a shared helper makes two + * stacks agree by construction rather than by both being right. + * + *

It fails when a recording is missing rather than comparing whatever happens to be present. A + * parity test that silently compares one lane to itself is the shape this whole exercise is + * supposed to prevent. That is also why it is tagged out of the ordinary {@code test} task and run + * by {@code webCrossStackParityTest}, which depends on all three recording lanes: in {@code test} + * alone the other two recordings do not exist yet, and a gate that fails for that reason teaches + * people to ignore it. + */ +@org.junit.jupiter.api.Tag("web-parity") +class CrossStackParityTest { + + private static final List LANES = List.of("tomcat", "jetty", "reactor-netty"); + + @Test + @DisplayName("every transport recorded the whole probe list") + void everyTransportRecorded() { + Map> recordings = readRecordings(); + + assertThat(recordings.keySet()) + .as( + "a missing recording means that lane did not run; comparing the rest would report" + + " parity across a matrix with a hole in it") + .containsExactlyInAnyOrderElementsOf(LANES); + recordings.forEach( + (lane, outcomes) -> + assertThat(outcomes.keySet()) + .as("%s did not record every probe", lane) + .containsExactlyInAnyOrderElementsOf( + WebPlatformContractSuite.probes().stream().map(WireProbe::name).toList())); + } + + @Test + @DisplayName("MVC and WebFlux expose the same problem and header contract") + void mvcAndWebFluxExposeSameProblemAndHeaderContract() { + Map> recordings = readRecordings(); + + Map tomcat = recordings.get("tomcat"); + Map reactive = recordings.get("reactor-netty"); + + // Compared as whole maps rather than probe by probe, so the failure message names every + // divergence at once instead of stopping at the first. + assertThat(normalize(reactive)) + .as("the reactive stack answers differently from the servlet stack") + .isEqualTo(normalize(tomcat)); + } + + @Test + @DisplayName("the two servlet containers agree with each other") + void servletContainersAgree() { + // Not implied by the test above. Container-specific behaviour — how a 405's Allow header is + // assembled, whether a bare status carries a content type — is exactly what differs between + // Tomcat and Jetty while both use the same platform code. + Map> recordings = readRecordings(); + + assertThat(normalize(recordings.get("jetty"))).isEqualTo(normalize(recordings.get("tomcat"))); + } + + @Test + @DisplayName("every failure carries a code on every transport") + void everyFailureCarriesACodeEverywhere() { + // The property the recording caught missing on the first run: Spring's own ProblemDetail is + // RFC 9457-shaped and carries no code, so it looks correct and gives a client nothing to + // branch on. + readRecordings() + .forEach( + (lane, outcomes) -> + outcomes.forEach( + (probe, outcome) -> { + if (outcome.status() >= 400) { + assertThat(outcome.problemCode()) + .as("%s answered %s with no problem code", lane, probe) + .isNotEmpty(); + assertThat(outcome.contentType()) + .as("%s answered %s without a problem document", lane, probe) + .isEqualTo("application/problem+json"); + } + })); + } + + private static Map normalize(Map outcomes) { + Map normalized = new TreeMap<>(); + outcomes.forEach((probe, outcome) -> normalized.put(probe, outcome.recorded())); + return normalized; + } + + private static Map> readRecordings() { + Path directory = WebContractFixture.recordingDirectory(); + Map> recordings = new LinkedHashMap<>(); + for (String lane : LANES) { + Path file = directory.resolve(lane + ".properties"); + if (!Files.exists(file)) { + continue; + } + recordings.put(lane, read(file)); + } + return recordings; + } + + private static Map read(Path file) { + Map outcomes = new LinkedHashMap<>(); + try { + for (String line : Files.readAllLines(file)) { + if (line.isBlank()) { + continue; + } + int equals = line.indexOf('='); + outcomes.put(line.substring(0, equals), WireOutcome.parse(line.substring(equals + 1))); + } + } catch (IOException e) { + throw new IllegalStateException("the recording at " + file + " could not be read", e); + } + return outcomes; + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/testkit/contract/TomcatContractRecordingIT.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/testkit/contract/TomcatContractRecordingIT.java new file mode 100644 index 00000000..625c9680 --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/testkit/contract/TomcatContractRecordingIT.java @@ -0,0 +1,26 @@ +package dev.caskeleton.adapter.inbound.web.testkit.contract; + +import dev.caskeleton.webtestkit.ContractFixtureApplication; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.test.context.ActiveProfiles; + +/** Records the wire contract as the servlet default serves it. */ +@SpringBootTest( + classes = ContractFixtureApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("web-contract") +class TomcatContractRecordingIT extends WebPlatformContractRecording { + + @LocalServerPort private int port; + + @Override + protected WebContractFixture fixture() { + return new WebContractFixture(port); + } + + @Override + protected String laneName() { + return "tomcat"; + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/testkit/fault/CommitThenConnectionResetIT.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/testkit/fault/CommitThenConnectionResetIT.java new file mode 100644 index 00000000..5756805a --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/testkit/fault/CommitThenConnectionResetIT.java @@ -0,0 +1,27 @@ +package dev.caskeleton.adapter.inbound.web.testkit.fault; + +import dev.caskeleton.webtestkit.ContractFixtureApplication; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.test.context.ActiveProfiles; + +/** + * The response-loss contract on the servlet default, over a real socket. + * + *

A real container rather than MockMvc, because the property being tested is what happens when a + * connection dies mid-response — and MockMvc has no connection to die. The mock path would report + * this contract as holding for a platform that never had it. + */ +@SpringBootTest( + classes = ContractFixtureApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("web-contract") +class CommitThenConnectionResetIT extends IdempotencyResponseLossContract { + + @LocalServerPort private int port; + + @Override + protected ResponseLossFixture fixture() { + return new HttpResponseLossFixture(port); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/testkit/mvc/TomcatWebContractIT.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/testkit/mvc/TomcatWebContractIT.java new file mode 100644 index 00000000..bf1880db --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/testkit/mvc/TomcatWebContractIT.java @@ -0,0 +1,44 @@ +package dev.caskeleton.adapter.inbound.web.testkit.mvc; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.webtestkit.ContractFixtureApplication; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.boot.web.server.servlet.context.ServletWebServerApplicationContext; +import org.springframework.test.context.ActiveProfiles; + +/** + * The status contract, observed over a real socket against a real Tomcat. + * + *

The assertions live in {@link WebContractAssertions} so the Jetty lane runs the same ones + * rather than a second copy that can drift. What this class adds is the container: an ephemeral + * port, a real socket, and a check that the server actually running is the one the lane claims. + */ +@SpringBootTest( + classes = ContractFixtureApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("web-contract") +class TomcatWebContractIT { + + @LocalServerPort private int port; + + @Autowired private ServletWebServerApplicationContext context; + + @Test + @DisplayName("the lane is running on Tomcat, not on whatever was first on the classpath") + void theLaneIsRunningOnTomcat() { + assertThat(context.getWebServer().getClass().getName()) + .as("a compatibility matrix that does not check which server it started proves nothing") + .contains("Tomcat"); + } + + @Test + @DisplayName("the whole Stable HTTP contract holds on Tomcat") + void theWholeStableContractHoldsOnTomcat() throws Exception { + new WebContractAssertions(port).assertWholeContract(); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/testkit/operation/TomcatOperationHttpIT.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/testkit/operation/TomcatOperationHttpIT.java new file mode 100644 index 00000000..39207a76 --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/testkit/operation/TomcatOperationHttpIT.java @@ -0,0 +1,26 @@ +package dev.caskeleton.adapter.inbound.web.testkit.operation; + +import dev.caskeleton.webtestkit.ContractFixtureApplication; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.test.context.ActiveProfiles; + +/** + * The operation resource contract on the servlet default, over a real socket. + * + *

Real, because every assertion here is about a response header, and MockMvc reports headers the + * container is free to drop or rewrite. + */ +@SpringBootTest( + classes = ContractFixtureApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("web-contract") +class TomcatOperationHttpIT extends OperationHttpContract { + + @LocalServerPort private int port; + + @Override + protected HttpOperationFixture fixture() { + return new HttpOperationFixture(port); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/testkit/order/MvcPipelineOrderIT.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/testkit/order/MvcPipelineOrderIT.java new file mode 100644 index 00000000..db3d28a8 --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/testkit/order/MvcPipelineOrderIT.java @@ -0,0 +1,27 @@ +package dev.caskeleton.adapter.inbound.web.testkit.order; + +import dev.caskeleton.webtestkit.PipelineOrderFixtureApplication; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.test.context.ActiveProfiles; + +/** + * Pipeline order on the servlet default, over a real socket. + * + *

A real container is required rather than preferred: MockMvc resolves a {@code Callable} inline + * and never performs the ASYNC redispatch, so the duplicate-observation cases would pass without + * the mechanism that causes them ever having run. + */ +@SpringBootTest( + classes = PipelineOrderFixtureApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("web-contract") +class MvcPipelineOrderIT extends WebPipelineOrderContract { + + @LocalServerPort private int port; + + @Override + protected HttpPipelineFixture fixture() { + return new HttpPipelineFixture(port); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/testkit/performance/TomcatLoadAndShutdownIT.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/testkit/performance/TomcatLoadAndShutdownIT.java new file mode 100644 index 00000000..e1fef5b5 --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/testkit/performance/TomcatLoadAndShutdownIT.java @@ -0,0 +1,63 @@ +package dev.caskeleton.adapter.inbound.web.testkit.performance; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.webtestkit.ContractFixtureApplication; +import java.time.Duration; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.boot.web.server.servlet.context.ServletWebServerApplicationContext; +import org.springframework.test.context.ActiveProfiles; + +/** + * Load, abuse and graceful shutdown on the servlet default. + * + *

Shutdown is asserted per container rather than once, because it is implemented per container. + * "Stop accepting, finish what is in flight" is a promise each server keeps in its own way, and the + * failure — a request cut off mid-response during a rolling deploy — looks to the client exactly + * like the network. + */ +@SpringBootTest( + classes = ContractFixtureApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = "server.shutdown=graceful") +@ActiveProfiles("web-contract") +class TomcatLoadAndShutdownIT extends WebLoadAndShutdownContract { + + @LocalServerPort private int port; + + @Autowired private ServletWebServerApplicationContext context; + + @Override + protected WebLoadFixture fixture() { + return new WebLoadFixture(port); + } + + @Override + protected org.springframework.boot.web.server.WebServer webServer() { + return context.getWebServer(); + } + + @Test + @Tag("web-shutdown") + @DisplayName("graceful shutdown drains within the deployment's grace period") + void gracefulShutdownFinishesInFlightWork() { + // Last, and destructive: the context serves nothing afterwards. + WebLoadFixture fixture = fixture(); + assertThat(fixture.stillServing(loadPath())).isTrue(); + + GracefulShutdownProbe.Outcome outcome = + GracefulShutdownProbe.shutDown(webServer(), Duration.ofSeconds(10)); + + // Bounded on purpose. A shutdown that waits indefinitely for a connection to go idle is how a + // rolling deploy stalls with half the fleet drained — and it produces no error to alert on. + assertThat(outcome.took()).isLessThan(Duration.ofSeconds(15)); + assertThat(fixture.stillServing(loadPath())) + .as("the server accepted a new request after it was told to stop") + .isFalse(); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/testkit/throttle/TomcatWebThrottleIT.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/testkit/throttle/TomcatWebThrottleIT.java new file mode 100644 index 00000000..3efaf4e3 --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/testkit/throttle/TomcatWebThrottleIT.java @@ -0,0 +1,41 @@ +package dev.caskeleton.adapter.inbound.web.testkit.throttle; + +import dev.caskeleton.webtestkit.ThrottleFixtureApplication; +import org.junit.jupiter.api.AfterEach; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.test.context.ActiveProfiles; + +/** + * Quota and capacity refusals on the servlet default, over a real socket. + * + *

Real, because the capacity case needs one request to genuinely occupy a slot while another + * arrives. A mock dispatcher runs them one after the other, so the second never meets a full + * service and the 503 case would pass without ever having been exercised. + */ +@SpringBootTest( + classes = ThrottleFixtureApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("web-contract") +class TomcatWebThrottleIT extends WebThrottleHttpContract { + + @LocalServerPort private int port; + + private HttpThrottleFixture fixture; + + @Override + protected HttpThrottleFixture fixture() { + if (fixture == null) { + fixture = new HttpThrottleFixture(port); + } + return fixture; + } + + @AfterEach + void closeFixture() { + if (fixture != null) { + fixture.close(); + fixture = null; + } + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/validation/WebValidationExceptionMapperTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/validation/WebValidationExceptionMapperTest.java new file mode 100644 index 00000000..7cca8a05 --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/validation/WebValidationExceptionMapperTest.java @@ -0,0 +1,125 @@ +package dev.caskeleton.adapter.inbound.web.validation; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.inbound.web.error.ProblemCode; +import dev.caskeleton.adapter.inbound.web.json.WebJsonDecodingException; +import dev.caskeleton.adapter.inbound.web.json.WebObjectMapperFactory; +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import tools.jackson.core.exc.StreamReadException; +import tools.jackson.databind.ObjectMapper; + +/** + * 400 means "I could not understand you"; 422 means "I understood and it is not acceptable". + * + *

Collapsing both into 400 is the common shortcut, and it leaves a client unable to tell a bug + * in its serialiser from a rejected form. + */ +class WebValidationExceptionMapperTest { + + private final WebValidationExceptionMapper mapper = new WebValidationExceptionMapper(); + + record CreateRequest(@NotBlank String title, @Min(1) int quantity) {} + + @Test + @DisplayName("malformed JSON is 400 and transport validation is 422") + void malformedJsonIs400AndTransportValidationIs422() { + assertThat(mapper.codeFor(new StreamReadException(null, "bad"))) + .isEqualTo(ProblemCode.MALFORMED_REQUEST); + assertThat(mapper.codeFor(new TransportValidationException("invalid title"))) + .isEqualTo(ProblemCode.VALIDATION_FAILED); + } + + @Test + @DisplayName("a real unknown property and a real scalar mismatch are binding failures") + void realReadFailuresAreBindingFailures() { + ObjectMapper strict = WebObjectMapperFactory.standard(); + + Throwable unknown = + catchFailure( + () -> + strict.readValue( + "{\"title\":\"a\",\"quantity\":1,\"typo\":1}", CreateRequest.class)); + Throwable mismatch = + catchFailure( + () -> strict.readValue("{\"title\":\"a\",\"quantity\":\"x\"}", CreateRequest.class)); + + assertThat(mapper.codeFor(unknown)).isEqualTo(ProblemCode.BINDING_FAILED); + assertThat(mapper.codeFor(mismatch)).isEqualTo(ProblemCode.BINDING_FAILED); + } + + @Test + @DisplayName("a decoding failure reports which side of the split it is on") + void aDecodingFailureReportsWhichSideOfTheSplit() { + assertThat(mapper.codeFor(new WebJsonDecodingException("not json", true, null))) + .isEqualTo(ProblemCode.MALFORMED_REQUEST); + assertThat(mapper.codeFor(new WebJsonDecodingException("bad shape", false, null))) + .isEqualTo(ProblemCode.BINDING_FAILED); + } + + @Test + @DisplayName("an unmapped failure throws rather than defaulting to a guess") + void anUnmappedFailureThrows() { + assertThat(mapper.handles(new IllegalStateException("who knows"))).isFalse(); + assertThatThrownBy(() -> mapper.codeFor(new IllegalStateException("who knows"))) + .as("a default status here would be a guess published to a client") + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("unmapped validation failure"); + assertThatThrownBy(() -> mapper.codeFor(null)).isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("bean validation becomes JSON Pointers and stable codes, never the submitted value") + void beanValidationBecomesPointersAndStableCodes() { + try (var factory = Validation.buildDefaultValidatorFactory()) { + Validator validator = factory.getValidator(); + var violations = validator.validate(new CreateRequest(" ", 0)); + + var issues = new WebValidationIssueMapper().toIssues(violations); + + assertThat(issues).hasSize(2); + assertThat(issues).extracting("pointer").containsExactly("/quantity", "/title"); + assertThat(issues) + .extracting("code") + .containsExactly("must-be-at-least", "must-not-be-blank"); + assertThat(issues) + .as("the submitted value must never be echoed back into an error body") + .noneMatch(issue -> issue.message().contains(" ")); + } + } + + @Test + @DisplayName("a property path becomes a JSON Pointer a client can act on") + void aPropertyPathBecomesAJsonPointer() { + assertThat(WebInputPointer.fromPropertyPath("lines[0].quantity")) + .isEqualTo("/lines/0/quantity"); + assertThat(WebInputPointer.fromPropertyPath("createOrder.arg0.title")) + .as("the argument segment names a Java signature, not anything the client sent") + .isEqualTo("/createOrder/title"); + assertThat(WebInputPointer.fromPropertyPath("")).isEqualTo(WebInputPointer.ROOT); + assertThat(WebInputPointer.of(List.of("a/b"))) + .as("a member named a/b must not address a nested member that does not exist") + .isEqualTo("/a~1b"); + } + + private static Throwable catchFailure(ThrowingCall call) { + try { + call.run(); + } catch (Exception failure) { + return failure; + } + throw new AssertionError("expected the strict mapper to refuse this document"); + } + + @FunctionalInterface + private interface ThrowingCall { + void run() throws Exception; + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/versioning/DeprecationHeaderWriterTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/versioning/DeprecationHeaderWriterTest.java new file mode 100644 index 00000000..c9b51117 --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/versioning/DeprecationHeaderWriterTest.java @@ -0,0 +1,107 @@ +package dev.caskeleton.adapter.inbound.web.versioning; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.net.URI; +import java.time.Instant; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** A deprecation notice has to be actionable, parseable, and true. */ +class DeprecationHeaderWriterTest { + + private static final Instant DEPRECATED = Instant.parse("2026-08-01T00:00:00Z"); + private static final Instant SUNSET = Instant.parse("2027-02-01T00:00:00Z"); + private static final Instant NOW = Instant.parse("2026-08-13T00:00:00Z"); + + private final DeprecationHeaderWriter writer = new DeprecationHeaderWriter(); + + @Test + @DisplayName("Sunset is an HTTP-date, not ISO 8601") + void sunsetIsAnHttpDate() { + Map headers = writer.headersFor(route(Optional.of(SUNSET))); + + assertThat(headers.get("Sunset")) + .as("RFC 8594 requires an IMF-fixdate; a client's parser rejects the ISO form") + .isEqualTo("Mon, 01 Feb 2027 00:00:00 GMT"); + assertThat(headers.get("Deprecation")).isEqualTo("Sat, 01 Aug 2026 00:00:00 GMT"); + } + + @Test + @DisplayName("the notice carries somewhere to go, not just an instruction to stop") + void theNoticeCarriesSomewhereToGo() { + Map headers = writer.headersFor(route(Optional.of(SUNSET))); + + assertThat(headers.get("Link")) + .contains("rel=\"deprecation\"") + .contains("https://docs.example.com/deprecations/orders-v1") + .contains("rel=\"successor-version\""); + } + + @Test + @DisplayName("a route with no committed sunset omits the header rather than inventing a date") + void aRouteWithNoCommittedSunsetOmitsTheHeader() { + Map headers = writer.headersFor(route(Optional.empty())); + + assertThat(headers).containsKeys("Deprecation", "Link").doesNotContainKey("Sunset"); + } + + @Test + @DisplayName("a sunset before the deprecation is refused at construction") + void aSunsetBeforeTheDeprecationIsRefused() { + assertThatThrownBy( + () -> + new DeprecatedRoute( + "GET /api/v1/orders", + DEPRECATED, + Optional.of(DEPRECATED.minusSeconds(1)), + URI.create("https://docs.example.com/x"), + Optional.empty())) + .as("a client would be told the route is already gone while it is still served") + .isInstanceOf(SunsetViolationException.class); + } + + @Test + @DisplayName("registering an already-passed sunset is refused") + void registeringAnAlreadyPassedSunsetIsRefused() { + ApiDeprecationPolicy policy = new ApiDeprecationPolicy(); + DeprecatedRoute expired = + new DeprecatedRoute( + "GET /api/v1/legacy", + DEPRECATED, + Optional.of(DEPRECATED.plusSeconds(60)), + URI.create("https://docs.example.com/x"), + Optional.empty()); + + assertThatThrownBy(() -> policy.register(expired, NOW)) + .as("publishing it would tell clients a route this deployment still serves is gone") + .isInstanceOf(SunsetViolationException.class); + } + + @Test + @DisplayName("a registered route is found and a duplicate is refused") + void aRegisteredRouteIsFoundAndADuplicateIsRefused() { + ApiDeprecationPolicy policy = new ApiDeprecationPolicy(); + policy.register(route(Optional.of(SUNSET)), NOW); + + assertThat(policy.deprecated("GET /api/v1/orders")).isTrue(); + assertThat(policy.deprecated("GET /api/v1/other")).isFalse(); + assertThat(writer.headersFor(policy, "GET /api/v1/other")).isEmpty(); + assertThatThrownBy(() -> policy.register(route(Optional.of(SUNSET)), NOW)) + .isInstanceOf(SunsetViolationException.class); + assertThatCode(() -> policy.registered()).doesNotThrowAnyException(); + } + + private static DeprecatedRoute route(Optional sunset) { + return new DeprecatedRoute( + "GET /api/v1/orders", + DEPRECATED, + sunset, + URI.create("https://docs.example.com/deprecations/orders-v1"), + Optional.of("/api/v2/orders")); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/versioning/PathApiVersionResolverTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/versioning/PathApiVersionResolverTest.java new file mode 100644 index 00000000..9fe7484c --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/versioning/PathApiVersionResolverTest.java @@ -0,0 +1,82 @@ +package dev.caskeleton.adapter.inbound.web.versioning; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.inbound.web.core.ApiMajorVersion; +import java.util.Set; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Version confusion routes a request to a contract the client did not ask for, so each way of + * getting the parse wrong is asserted. + */ +class PathApiVersionResolverTest { + + private final PathApiVersionResolver resolver = + new PathApiVersionResolver(ApiVersionCatalog.v1()); + + @Test + @DisplayName("the major version is read from a canonical path") + void theMajorVersionIsReadFromACanonicalPath() { + assertThat(resolver.resolve("/api/v1/documents")).isEqualTo(new ApiMajorVersion(1)); + assertThat(resolver.resolve("/api/v1")).isEqualTo(new ApiMajorVersion(1)); + } + + @Test + @DisplayName("the pattern is anchored, so a later segment is not the version") + void thePatternIsAnchored() { + assertThatThrownBy(() -> resolver.resolve("/internal/api/v1/documents")) + .as("an unanchored match would find a version anywhere in the path") + .isInstanceOf(UnsupportedApiVersionException.class); + } + + @Test + @DisplayName("v10 is not v1") + void tenIsNotOne() { + PathApiVersionResolver twoVersions = + new PathApiVersionResolver( + new ApiVersionCatalog(Set.of(new ApiMajorVersion(1), new ApiMajorVersion(10)))); + + assertThat(twoVersions.resolve("/api/v10/documents")) + .as("without a segment boundary /api/v10 reads as v1 and gets the wrong contract") + .isEqualTo(new ApiMajorVersion(10)); + } + + @Test + @DisplayName("a leading zero is not a second spelling of a version") + void aLeadingZeroIsNotASecondSpelling() { + assertThatThrownBy(() -> resolver.resolve("/api/v01/documents")) + .as("two spellings of one version are two cache entries for one resource") + .isInstanceOf(UnsupportedApiVersionException.class); + } + + @Test + @DisplayName("an unserved version is refused rather than falling through") + void anUnservedVersionIsRefused() { + assertThatThrownBy(() -> resolver.resolve("/api/v3/documents")) + .as("falling through would give a client written for v3 the semantics of v1") + .isInstanceOf(UnsupportedApiVersionException.class) + .hasMessageContaining("v3"); + assertThat(resolver.servedBy("/api/v3/documents")).isFalse(); + assertThat(resolver.servedBy("/api/v1/documents")).isTrue(); + } + + @Test + @DisplayName("a path with no version segment is refused") + void aPathWithNoVersionSegmentIsRefused() { + assertThatThrownBy(() -> resolver.resolve("/documents")) + .isInstanceOf(UnsupportedApiVersionException.class); + assertThatThrownBy(() -> resolver.resolve(null)) + .isInstanceOf(UnsupportedApiVersionException.class); + } + + @Test + @DisplayName("an API that serves no version is refused at construction") + void anApiThatServesNoVersionIsRefused() { + assertThatThrownBy(() -> new ApiVersionCatalog(Set.of())) + .isInstanceOf(IllegalArgumentException.class); + assertThat(ApiVersionCatalog.v1().supported()).containsExactly(new ApiMajorVersion(1)); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/webflux/autoconfigure/WebFluxPlatformAutoConfigurationTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/webflux/autoconfigure/WebFluxPlatformAutoConfigurationTest.java new file mode 100644 index 00000000..10e95f13 --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/webflux/autoconfigure/WebFluxPlatformAutoConfigurationTest.java @@ -0,0 +1,112 @@ +package dev.caskeleton.adapter.inbound.web.webflux.autoconfigure; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.inbound.web.error.WebProblemFactory; +import dev.caskeleton.adapter.inbound.web.mvc.autoconfigure.WebMvcPlatformAutoConfiguration; +import dev.caskeleton.adapter.inbound.web.mvc.filter.WebMvcEvidenceFilter; +import dev.caskeleton.adapter.inbound.web.webflux.context.WebFluxRequestContextFilter; +import dev.caskeleton.adapter.inbound.web.webflux.guard.BlockingDependencyGuard; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.boot.test.context.runner.ReactiveWebApplicationContextRunner; +import org.springframework.boot.test.context.runner.WebApplicationContextRunner; + +/** + * The reactive starter activates in a reactive application and nowhere else. + * + *

The mutual exclusion is asserted from both sides. Spring Boot deduces exactly one application + * type, so the two roots can never both activate — a stronger guarantee than a startup check, + * because it holds before any bean is created. Proving it means running each root under the other's + * application type. + */ +class WebFluxPlatformAutoConfigurationTest { + + private final ReactiveWebApplicationContextRunner reactive = + new ReactiveWebApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(WebFluxPlatformAutoConfiguration.class)); + + @Test + @DisplayName("a reactive application gets the reactive wiring") + void aReactiveApplicationGetsTheReactiveWiring() { + reactive.run( + context -> + assertThat(context) + .hasNotFailed() + .hasSingleBean(WebFluxRequestContextFilter.class) + .hasSingleBean(BlockingDependencyGuard.class) + .hasSingleBean(WebProblemFactory.class)); + } + + @Test + @DisplayName("the reactive starter brings no servlet wiring") + void theReactiveStarterBringsNoServletWiring() { + reactive.run( + context -> + assertThat(context) + .as("the design forbids the reactive starter pulling MVC or servlet types in") + .doesNotHaveBean(WebMvcEvidenceFilter.class)); + } + + @Test + @DisplayName("both roots on the classpath still activate only one") + void bothRootsOnTheClasspathStillActivateOnlyOne() { + new ReactiveWebApplicationContextRunner() + .withConfiguration( + AutoConfigurations.of( + WebFluxPlatformAutoConfiguration.class, WebMvcPlatformAutoConfiguration.class)) + .run( + context -> + assertThat(context) + .hasSingleBean(WebFluxRequestContextFilter.class) + .doesNotHaveBean(WebMvcEvidenceFilter.class)); + + new WebApplicationContextRunner() + .withConfiguration( + AutoConfigurations.of( + WebFluxPlatformAutoConfiguration.class, WebMvcPlatformAutoConfiguration.class)) + .run( + context -> + assertThat(context) + .hasSingleBean(WebMvcEvidenceFilter.class) + .doesNotHaveBean(WebFluxRequestContextFilter.class)); + } + + @Test + @DisplayName("a non-web application gets neither") + void aNonWebApplicationGetsNeither() { + new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(WebFluxPlatformAutoConfiguration.class)) + .run(context -> assertThat(context).doesNotHaveBean(WebFluxRequestContextFilter.class)); + } + + @Test + @DisplayName("the master switch turns everything off") + void theMasterSwitchTurnsEverythingOff() { + reactive + .withPropertyValues("backend.web.webflux.enabled=false") + .run(context -> assertThat(context).doesNotHaveBean(WebFluxRequestContextFilter.class)); + } + + @Test + @DisplayName("the blocking guard is on unless a deployment turns it off") + void theBlockingGuardIsOnUnlessTurnedOff() { + assertThat(WebFluxPlatformSettings.defaults().blockingGuardEnabled()) + .as("a guard that must be switched on is one the least careful deployments do not have") + .isTrue(); + reactive + .withPropertyValues("backend.web.webflux.blocking-guard-enabled=false") + .run(context -> assertThat(context).doesNotHaveBean(BlockingDependencyGuard.class)); + } + + @Test + @DisplayName("a non-positive request budget is refused at binding time") + void aNonPositiveRequestBudgetIsRefused() { + assertThat(WebFluxPlatformSettings.defaults().requestBudgetSeconds()).isEqualTo(10); + org.assertj.core.api.Assertions.assertThatThrownBy( + () -> new WebFluxPlatformSettings(true, false, true, 0)) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/webflux/context/WebFluxRequestContextAccessorTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/webflux/context/WebFluxRequestContextAccessorTest.java new file mode 100644 index 00000000..a9023edb --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/webflux/context/WebFluxRequestContextAccessorTest.java @@ -0,0 +1,93 @@ +package dev.caskeleton.adapter.inbound.web.webflux.context; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.inbound.web.core.ActorContext; +import dev.caskeleton.adapter.inbound.web.core.ApiMajorVersion; +import dev.caskeleton.adapter.inbound.web.core.ExternalRequestContext; +import dev.caskeleton.adapter.inbound.web.core.TenantContext; +import dev.caskeleton.adapter.inbound.web.core.WebOperationName; +import dev.caskeleton.adapter.inbound.web.core.WebRequestContext; +import dev.caskeleton.adapter.inbound.web.core.WebRequestId; +import dev.caskeleton.adapter.inbound.web.core.WebTraceId; +import dev.caskeleton.adapter.inbound.web.evidence.WebExecutionEvidenceTracker; +import java.time.Instant; +import java.util.Locale; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; +import reactor.util.context.Context; + +/** A lost context fails the request instead of quietly becoming an anonymous actor. */ +class WebFluxRequestContextAccessorTest { + + private static final Instant NOW = Instant.parse("2026-08-13T00:00:00Z"); + + @Test + @DisplayName("the context is read back from the Reactor Context across a thread hop") + void theContextIsReadBackAcrossAThreadHop() { + WebRequestContext expected = context(); + + StepVerifier.create( + WebFluxRequestContextAccessor.require() + .publishOn(reactor.core.scheduler.Schedulers.boundedElastic()) + .contextWrite( + ctx -> + WebFluxRequestContextAccessor.write( + ctx, expected, WebExecutionEvidenceTracker.received()))) + .expectNext(expected) + .verifyComplete(); + } + + @Test + @DisplayName("a missing context errors rather than completing empty") + void aMissingContextErrorsRatherThanCompletingEmpty() { + StepVerifier.create(WebFluxRequestContextAccessor.require()) + .expectErrorSatisfies( + failure -> + assertThat(failure) + .as("an empty Mono lets a downstream switchIfEmpty supply a default actor") + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("silent authorization change")) + .verify(); + } + + @Test + @DisplayName("the evidence tracker travels with the context") + void theEvidenceTrackerTravelsWithTheContext() { + WebExecutionEvidenceTracker tracker = WebExecutionEvidenceTracker.received(); + + StepVerifier.create( + WebFluxRequestContextAccessor.requireTracker() + .contextWrite(ctx -> WebFluxRequestContextAccessor.write(ctx, context(), tracker))) + .expectNext(tracker) + .verifyComplete(); + } + + @Test + @DisplayName("a context view without the key reports absence rather than throwing") + void aContextViewWithoutTheKeyReportsAbsence() { + assertThat(WebFluxRequestContextAccessor.find(Context.empty())).isEmpty(); + assertThat( + WebFluxRequestContextAccessor.find( + WebFluxRequestContextAccessor.write( + Context.empty(), context(), WebExecutionEvidenceTracker.received()))) + .isPresent(); + StepVerifier.create(Mono.just(1)).expectNext(1).verifyComplete(); + } + + private static WebRequestContext context() { + return new WebRequestContext( + new WebRequestId("req-1"), + new WebTraceId("0af7651916cd43dd8448eb211c80319c"), + new WebOperationName("orders.list"), + new ApiMajorVersion(1), + ActorContext.anonymous(), + TenantContext.none(), + Locale.ENGLISH, + NOW, + NOW.plusSeconds(5), + new ExternalRequestContext("https", "api.example.com", 443, "")); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/webflux/guard/BlockingDependencyGuardTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/webflux/guard/BlockingDependencyGuardTest.java new file mode 100644 index 00000000..6dd0ac4a --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/webflux/guard/BlockingDependencyGuardTest.java @@ -0,0 +1,92 @@ +package dev.caskeleton.adapter.inbound.web.webflux.guard; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * The guard has to fail on the event loop and stay out of the way everywhere else. + * + *

Both halves matter. A guard that never fires is the silent capacity bug it was written to + * prevent; a guard that fires on a worker thread makes the controlled bridge impossible and gets + * switched off. + */ +class BlockingDependencyGuardTest { + + @Test + @DisplayName("a blocking call on a reactor http thread is refused") + void aBlockingCallOnAReactorHttpThreadIsRefused() throws Exception { + BlockingDependencyGuard guard = BlockingDependencyGuard.reactorNetty(); + AtomicReference caught = new AtomicReference<>(); + + Thread eventLoop = + new Thread( + () -> { + try { + guard.check("jpa"); + } catch (Throwable failure) { + caught.set(failure); + } + }, + "reactor-http-nio-1"); + eventLoop.start(); + eventLoop.join(5_000); + + assertThat(caught.get()) + .as( + "a blocking call here succeeds slowly while holding a thread that serves every connection") + .isInstanceOf(BlockingCallDetectedException.class); + assertThat(caught.get().getMessage()).contains("jpa").contains("reactor-http-nio-1"); + } + + @Test + @DisplayName("the same call on a worker thread is allowed") + void theSameCallOnAWorkerThreadIsAllowed() throws Exception { + BlockingDependencyGuard guard = BlockingDependencyGuard.reactorNetty(); + AtomicReference caught = new AtomicReference<>(); + + Thread worker = + new Thread( + () -> { + try { + guard.check("jpa"); + } catch (Throwable failure) { + caught.set(failure); + } + }, + "boundedElastic-1"); + worker.start(); + worker.join(5_000); + + assertThat(caught.get()) + .as( + "firing on a worker would make the controlled bridge impossible and get the guard switched off") + .isNull(); + } + + @Test + @DisplayName("the notion of an event loop is a predicate, not a hard-coded name") + void theNotionOfAnEventLoopIsAPredicate() { + BlockingDependencyGuard custom = + new BlockingDependencyGuard(name -> name.startsWith("custom-loop-")); + + assertThat(custom.onEventLoop()).isFalse(); + assertThatCode(() -> custom.check("mongo")).doesNotThrowAnyException(); + assertThatThrownBy(() -> new BlockingDependencyGuard(null)) + .isInstanceOf(NullPointerException.class); + } + + @Test + @DisplayName("both reactor loop families are covered") + void bothReactorLoopFamiliesAreCovered() { + assertThat(BlockingDependencyGuard.REACTOR_NETTY_EVENT_LOOP.test("reactor-http-nio-3")) + .isTrue(); + assertThat(BlockingDependencyGuard.REACTOR_NETTY_EVENT_LOOP.test("reactor-tcp-nio-1")).isTrue(); + assertThat(BlockingDependencyGuard.REACTOR_NETTY_EVENT_LOOP.test("main")).isFalse(); + assertThat(BlockingDependencyGuard.REACTOR_NETTY_EVENT_LOOP.test(null)).isFalse(); + } +} diff --git a/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/webflux/idempotency/WebFluxIdempotentInvokerTest.java b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/webflux/idempotency/WebFluxIdempotentInvokerTest.java new file mode 100644 index 00000000..aa70c62a --- /dev/null +++ b/src/adapter/inbound/web/src/test/java/dev/caskeleton/adapter/inbound/web/webflux/idempotency/WebFluxIdempotentInvokerTest.java @@ -0,0 +1,268 @@ +package dev.caskeleton.adapter.inbound.web.webflux.idempotency; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.inbound.web.core.ActorContext; +import dev.caskeleton.adapter.inbound.web.core.ApiMajorVersion; +import dev.caskeleton.adapter.inbound.web.core.ExternalRequestContext; +import dev.caskeleton.adapter.inbound.web.core.TenantContext; +import dev.caskeleton.adapter.inbound.web.core.WebOperationName; +import dev.caskeleton.adapter.inbound.web.core.WebRequestContext; +import dev.caskeleton.adapter.inbound.web.core.WebRequestId; +import dev.caskeleton.adapter.inbound.web.core.WebTraceId; +import dev.caskeleton.adapter.inbound.web.error.WebProblemFactory; +import dev.caskeleton.adapter.inbound.web.http.ApiHeaders; +import dev.caskeleton.adapter.inbound.web.idempotency.DeterministicCommandEncoder; +import dev.caskeleton.adapter.inbound.web.idempotency.FingerprintHeaderPolicy; +import dev.caskeleton.adapter.inbound.web.idempotency.IdempotencyResponsePlan; +import dev.caskeleton.adapter.inbound.web.idempotency.SemanticRequestFingerprintFactory; +import dev.caskeleton.adapter.inbound.web.idempotency.WebIdempotencyGate; +import dev.caskeleton.adapter.inbound.web.json.WebObjectMapperFactory; +import dev.caskeleton.adapter.inbound.web.operation.WebOperationProfile; +import dev.caskeleton.application.idempotency.IdempotencyRecord; +import dev.caskeleton.application.idempotency.IdempotencyScope; +import dev.caskeleton.application.idempotency.IdempotencyStatus; +import dev.caskeleton.application.idempotency.IdempotencyStorePort; +import dev.caskeleton.application.idempotency.RequestFingerprint; +import dev.caskeleton.application.idempotency.StoredResponse; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArraySet; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.web.server.MockServerWebExchange; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; +import reactor.test.StepVerifier; + +/** + * The reactive binding, held to the same answers as the servlet one plus one it alone can get + * wrong. + * + *

That extra one is thread affinity. The store behind this gate is JDBC in this repository, and + * a JDBC call on an event-loop thread stalls every other request that loop carries — a defect no + * assertion about status codes would ever notice. + */ +class WebFluxIdempotentInvokerTest { + + private static final Instant NOW = Instant.parse("2026-08-25T10:00:00Z"); + private static final String KEY = "key-00000001"; + private static final WebOperationProfile PROFILE = WebOperationProfile.create("transfers.create"); + + record Transfer(String to, int amount) {} + + record Receipt(String id) {} + + private final RecordingStore store = new RecordingStore(); + private final WebIdempotencyGate gate = + new WebIdempotencyGate( + store, + new SemanticRequestFingerprintFactory( + new DeterministicCommandEncoder(WebObjectMapperFactory.standard()), + FingerprintHeaderPolicy.standard()), + Clock.fixed(NOW, ZoneOffset.UTC), + Duration.ofHours(24)); + private final WebFluxIdempotentInvoker invoker = + new WebFluxIdempotentInvoker( + gate, + WebObjectMapperFactory.standardJsonMapper(), + WebProblemFactory.standard(), + Schedulers.boundedElastic()); + private final AtomicInteger handlerRuns = new AtomicInteger(); + + private Mono> post(Object command) { + MockServerWebExchange exchange = + MockServerWebExchange.from( + MockServerHttpRequest.post("/transfers").header(ApiHeaders.IDEMPOTENCY_KEY, KEY)); + return invoker.invoke( + exchange, + context(), + PROFILE, + Map.of(), + command, + Mono.fromSupplier( + () -> { + handlerRuns.incrementAndGet(); + return new Receipt("t-1"); + }), + 201); + } + + @Test + @DisplayName("a first request runs the handler and answers 201") + void firstRequestRuns() { + StepVerifier.create(post(new Transfer("bob", 100))) + .assertNext( + response -> { + assertThat(response.getStatusCode().value()).isEqualTo(201); + assertThat(response.getBody()).isEqualTo("{\"id\":\"t-1\"}"); + }) + .verifyComplete(); + assertThat(handlerRuns).hasValue(1); + } + + @Test + @DisplayName("a retry replays the stored response without running the handler again") + void retryReplays() { + post(new Transfer("bob", 100)).block(); + + StepVerifier.create(post(new Transfer("bob", 100))) + .assertNext( + response -> { + assertThat(response.getStatusCode().value()).isEqualTo(201); + assertThat(response.getBody()).isEqualTo("{\"id\":\"t-1\"}"); + assertThat(response.getHeaders().getFirst(IdempotencyResponsePlan.REPLAYED_HEADER)) + .isEqualTo("true"); + }) + .verifyComplete(); + assertThat(handlerRuns).hasValue(1); + } + + @Test + @DisplayName("a reused key with a different body is 422") + void reusedKeyIsUnprocessable() { + post(new Transfer("bob", 100)).block(); + + StepVerifier.create(post(new Transfer("bob", 900))) + .assertNext( + response -> { + assertThat(response.getStatusCode().value()).isEqualTo(422); + assertThat(response.getBody()).contains("IDEMPOTENCY_KEY_REUSED"); + }) + .verifyComplete(); + assertThat(handlerRuns).hasValue(1); + } + + @Test + @DisplayName("a collision with an in-flight attempt is 409 with a Retry-After") + void inFlightCollisionIsConflict() { + Mono> outer = + invoker.invoke( + MockServerWebExchange.from( + MockServerHttpRequest.post("/transfers").header(ApiHeaders.IDEMPOTENCY_KEY, KEY)), + context(), + PROFILE, + Map.of(), + new Transfer("bob", 100), + Mono.defer(() -> post(new Transfer("bob", 100)).map(retry -> retry)) + .doOnNext( + retry -> { + assertThat(retry.getStatusCode().value()).isEqualTo(409); + assertThat( + retry + .getHeaders() + .getFirst(IdempotencyResponsePlan.RETRY_AFTER_HEADER)) + .isEqualTo("1"); + }), + 201); + + StepVerifier.create(outer).expectNextCount(1).verifyComplete(); + } + + @Test + @DisplayName("the two stacks agree: MVC and WebFlux answer the same collision the same way") + void bothStacksShareTheDecision() { + // Not a duplicate of the MVC test. It asserts the property that makes the shared plan worth + // having: whichever container serves the request, the client sees the same contract. + post(new Transfer("bob", 100)).block(); + + ResponseEntity mismatch = post(new Transfer("bob", 900)).block(); + + assertThat(mismatch).isNotNull(); + assertThat(mismatch.getStatusCode().value()) + .isEqualTo( + IdempotencyResponsePlan.of( + dev.caskeleton.adapter.inbound.web.idempotency.IdempotencyAdmission + .fingerprintMismatch( + new IdempotencyRecord( + IdempotencyScope.of("alice", KEY, "transfers.create"), + new RequestFingerprint("0".repeat(64)), + IdempotencyStatus.IN_FLIGHT, + null, + NOW, + NOW.plusSeconds(60))), + 201) + .status()); + } + + @Test + @DisplayName("no store call runs on the calling thread") + void storeNeverRunsOnTheCallingThread() { + // block() makes the caller the test thread, which is not an event loop — so the assertion is + // the general one: the store is never touched by whoever subscribed. On a real Netty worker + // that is the difference between a slow request and a stalled connection. + String caller = Thread.currentThread().getName(); + + post(new Transfer("bob", 100)).block(); + + assertThat(store.threads).isNotEmpty().doesNotContain(caller); + } + + private static WebRequestContext context() { + return new WebRequestContext( + new WebRequestId("req-1"), + new WebTraceId("0af7651916cd43dd8448eb211c80319c"), + new WebOperationName("transfers.create"), + new ApiMajorVersion(1), + ActorContext.authenticated("alice", Set.of()), + TenantContext.none(), + Locale.ENGLISH, + NOW, + NOW.plusSeconds(5), + new ExternalRequestContext("https", "api.example.com", 443, "")); + } + + private static final class RecordingStore implements IdempotencyStorePort { + + private final Map records = new ConcurrentHashMap<>(); + private final Set threads = new CopyOnWriteArraySet<>(); + + @Override + public boolean tryBegin( + IdempotencyScope scope, RequestFingerprint fingerprint, Instant expiresAt) { + threads.add(Thread.currentThread().getName()); + return records.putIfAbsent( + scope, + new IdempotencyRecord( + scope, fingerprint, IdempotencyStatus.IN_FLIGHT, null, Instant.EPOCH, expiresAt)) + == null; + } + + @Override + public Optional find(IdempotencyScope scope, Instant now) { + threads.add(Thread.currentThread().getName()); + return Optional.ofNullable(records.get(scope)).filter(record -> !record.isExpiredAt(now)); + } + + @Override + public void complete(IdempotencyScope scope, StoredResponse response) { + threads.add(Thread.currentThread().getName()); + records.computeIfPresent( + scope, + (key, record) -> + new IdempotencyRecord( + record.scope(), + record.fingerprint(), + IdempotencyStatus.COMPLETED, + response, + record.createdAt(), + record.expiresAt())); + } + + @Override + public void discard(IdempotencyScope scope) { + threads.add(Thread.currentThread().getName()); + records.remove(scope); + } + } +} diff --git a/src/adapter/inbound/web/src/test/resources/application-web-contract.yaml b/src/adapter/inbound/web/src/test/resources/application-web-contract.yaml new file mode 100644 index 00000000..e5fcde1a --- /dev/null +++ b/src/adapter/inbound/web/src/test/resources/application-web-contract.yaml @@ -0,0 +1,19 @@ +# The profile the real-container contract gate runs under. +# +# Everything is switched off except the servlet transport itself. The gate is about the status +# contract on the wire, and a security chain or a file-server profile joining the context would make +# a failure here ambiguous between "the contract broke" and "an unrelated capability did". +spring: + main: + banner-mode: "off" + mvc: + problemdetails: + enabled: true +server: + error: + include-stacktrace: never + include-message: never +backend: + web: + mvc: + enabled: true diff --git a/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/arch/ForbiddenClassAnnotationCondition.java b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/arch/ForbiddenClassAnnotationCondition.java new file mode 100644 index 00000000..b4cbea7f --- /dev/null +++ b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/arch/ForbiddenClassAnnotationCondition.java @@ -0,0 +1,40 @@ +package dev.caskeleton.adapter.inbound.web.testkit.arch; + +import com.tngtech.archunit.core.domain.JavaAnnotation; +import com.tngtech.archunit.core.domain.JavaClass; +import com.tngtech.archunit.lang.ArchCondition; +import com.tngtech.archunit.lang.ConditionEvents; +import com.tngtech.archunit.lang.SimpleConditionEvent; +import java.util.Set; + +/** + * Fails a class that carries one of a named set of annotations. + * + *

Matched by fully qualified name rather than by class literal so the rule can name an + * annotation this leaf does not have on its classpath. {@code jakarta.transaction.Transactional} is + * the example that matters: a deployment that carries it can annotate a controller with it, and a + * rule written only against the Spring annotation would pass while the boundary is broken. + */ +final class ForbiddenClassAnnotationCondition extends ArchCondition { + + private final Set forbidden; + + ForbiddenClassAnnotationCondition(Set forbidden) { + super("be annotated with " + forbidden); + this.forbidden = Set.copyOf(forbidden); + } + + @Override + public void check(JavaClass element, ConditionEvents events) { + for (JavaAnnotation annotation : element.getAnnotations()) { + String name = annotation.getRawType().getName(); + if (forbidden.contains(name)) { + events.add( + SimpleConditionEvent.satisfied( + element, element.getDescription() + " is annotated with " + name)); + return; + } + } + events.add(SimpleConditionEvent.violated(element, element.getDescription() + " is clean")); + } +} diff --git a/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/arch/ForbiddenMethodAnnotationCondition.java b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/arch/ForbiddenMethodAnnotationCondition.java new file mode 100644 index 00000000..40e9aed2 --- /dev/null +++ b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/arch/ForbiddenMethodAnnotationCondition.java @@ -0,0 +1,40 @@ +package dev.caskeleton.adapter.inbound.web.testkit.arch; + +import com.tngtech.archunit.core.domain.JavaAnnotation; +import com.tngtech.archunit.core.domain.JavaMethod; +import com.tngtech.archunit.lang.ArchCondition; +import com.tngtech.archunit.lang.ConditionEvents; +import com.tngtech.archunit.lang.SimpleConditionEvent; +import java.util.Set; + +/** + * Fails a method that carries one of a named set of annotations. + * + *

Matched by fully qualified name rather than by class literal so the rule can name an + * annotation this leaf does not have on its classpath. {@code jakarta.transaction.Transactional} is + * the example that matters: a deployment that carries it can annotate a controller with it, and a + * rule written only against the Spring annotation would pass while the boundary is broken. + */ +final class ForbiddenMethodAnnotationCondition extends ArchCondition { + + private final Set forbidden; + + ForbiddenMethodAnnotationCondition(Set forbidden) { + super("be annotated with " + forbidden); + this.forbidden = Set.copyOf(forbidden); + } + + @Override + public void check(JavaMethod element, ConditionEvents events) { + for (JavaAnnotation annotation : element.getAnnotations()) { + String name = annotation.getRawType().getName(); + if (forbidden.contains(name)) { + events.add( + SimpleConditionEvent.satisfied( + element, element.getDescription() + " is annotated with " + name)); + return; + } + } + events.add(SimpleConditionEvent.violated(element, element.getDescription() + " is clean")); + } +} diff --git a/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/arch/WebArchitectureRules.java b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/arch/WebArchitectureRules.java new file mode 100644 index 00000000..13bd4399 --- /dev/null +++ b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/arch/WebArchitectureRules.java @@ -0,0 +1,238 @@ +package dev.caskeleton.adapter.inbound.web.testkit.arch; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noMethods; + +import com.tngtech.archunit.base.DescribedPredicate; +import com.tngtech.archunit.core.domain.JavaClass; +import com.tngtech.archunit.lang.ArchRule; + +/** + * The reusable rules that keep the controller a use case adapter. + * + *

These live in the testkit rather than in {@code main} because ArchUnit is a test library: + * shipping the rule pack in production would put it on every deployment classpath to serve code + * that only runs in a test. + * + *

Each rule encodes a boundary the design states and that nothing else can enforce. A Gradle + * dependency gate cannot see that a controller injected a repository, because the repository is a + * legal dependency of the leaf; only a rule that reads the controller can. + */ +public final class WebArchitectureRules { + + private WebArchitectureRules() {} + + /** + * A controller reaches the application, never persistence or an outbound client. + * + *

A controller holding a repository has skipped the application entirely: there is no use case + * to test, no transaction boundary anybody declared, and no port a second transport could reuse. + */ + public static ArchRule controllersAreUseCaseAdapters() { + return noClasses() + .that() + .resideInAnyPackage("..web..controller..", "..web.controller..") + .should() + .dependOnClassesThat(inForbiddenInfrastructure()) + .as("controllers do not reach persistence or an outbound client directly") + .because( + "a controller holding a repository has skipped the application: no use case to test," + + " no declared transaction boundary, and no port a second transport can reuse"); + } + + /** + * No transaction boundary is declared on a controller. + * + *

A transaction that opens at the controller stays open for response serialisation, so a slow + * client holds a database connection. It also puts the boundary where no business rule can see + * it: the application cannot decide to commit early or split the unit of work. + */ + public static ArchRule controllersDeclareNoTransaction() { + return noMethods() + .that() + .areDeclaredInClassesThat() + .resideInAnyPackage("..web..controller..", "..web.controller..") + .should( + new ForbiddenMethodAnnotationCondition( + WebForbiddenTypeCatalog.FORBIDDEN_CONTROLLER_ANNOTATIONS)) + .as("controller methods declare no transaction") + .because( + "a transaction opened at the controller stays open for response serialisation, so a" + + " slow client holds a database connection"); + } + + /** The same rule at class level, because an annotation on the type covers every method. */ + public static ArchRule controllerTypesDeclareNoTransaction() { + return noClasses() + .that() + .resideInAnyPackage("..web..controller..", "..web.controller..") + .should( + new ForbiddenClassAnnotationCondition( + WebForbiddenTypeCatalog.FORBIDDEN_CONTROLLER_ANNOTATIONS)) + .as("controller types declare no transaction") + .because("a class-level annotation is the same boundary in a place that is easier to miss"); + } + + /** + * A persistence type never becomes a wire model. + * + *

An entity serialised into a response drags its lazy associations into the response writer, + * outside the transaction, where they either fail or issue queries from the view layer. It also + * publishes the database schema as the API: renaming a column becomes a breaking change. + */ + public static ArchRule wireModelsAreNotPersistenceTypes() { + return noClasses() + .that() + .resideInAnyPackage("..web..dto..", "..web..envelope..", "..web..contract..") + .should() + .dependOnClassesThat(annotatedAsPersistence()) + .as("request and response models are not persistence types") + .because( + "an entity in a response serialises lazy associations after the transaction closed, and" + + " publishes the database schema as the API contract"); + } + + /** + * The application and the domain never see a transport type. + * + *

A use case that takes a servlet request cannot be called by another transport or unit + * tested; an application that builds a {@code ProblemDetail} has chosen an HTTP status, which is + * the transport's decision. + */ + public static ArchRule applicationDoesNotSeeTransportTypes() { + return noClasses() + .that() + .resideInAnyPackage("dev.caskeleton.application..", "dev.caskeleton.domain..") + .should() + .dependOnClassesThat(isForbiddenTransportType()) + .as("application and domain packages hold no transport type") + .because( + "a use case that takes a servlet request cannot be reached by another transport and" + + " cannot be unit tested without a container"); + } + + /** + * No global response envelope exists. + * + *

The design forbids it: an envelope puts a second status inside a response that already has + * one, and makes every response a custom media type in practice, which costs the platform every + * generic HTTP tool that understands {@code 304}, a schema or a {@code Content-Type}. + */ + public static ArchRule noGlobalResponseEnvelope() { + return noClasses() + .that() + .resideInAPackage("dev.caskeleton.adapter.inbound.web..") + .should() + .haveSimpleName("ApiResponse") + .orShould() + .haveSimpleName("CommonResponse") + .orShould() + .haveSimpleName("ResponseWrapper") + .as("no global response envelope type exists") + .because( + "an envelope puts a second status inside a response that already has one, and a client" + + " has two places to look that can disagree"); + } + + /** + * The rules that can be decided from the web leaf alone. + * + *

Split from {@link #crossLeafRules()} because a rule whose {@code that()} clause matches + * nothing does not pass — ArchUnit fails it as an empty evaluation, which is the right answer. + * Running the application-scoped rule against an import of only the web tree would either fail + * for the wrong reason or, with the empty check disabled, report coverage it does not have. + */ + public static java.util.List webScopedRules() { + return java.util.List.of( + controllersAreUseCaseAdapters(), + controllersDeclareNoTransaction(), + controllerTypesDeclareNoTransaction(), + wireModelsAreNotPersistenceTypes(), + noGlobalResponseEnvelope(), + stableDoesNotDependOnAdvanced()); + } + + /** + * The rules that need more than one leaf in the import. + * + *

These run at the composition root, the only place that sees the application, the domain and + * every transport at once. + */ + public static java.util.List crossLeafRules() { + return java.util.List.of(applicationDoesNotSeeTransportTypes()); + } + + /** Every rule, for a suite that must not silently run a subset. */ + public static java.util.List all() { + return java.util.stream.Stream.concat(webScopedRules().stream(), crossLeafRules().stream()) + .toList(); + } + + private static DescribedPredicate inForbiddenInfrastructure() { + return new DescribedPredicate<>("reside in persistence or outbound client packages") { + @Override + public boolean test(JavaClass candidate) { + return WebForbiddenTypeCatalog.insideAny( + candidate.getPackageName(), WebForbiddenTypeCatalog.PERSISTENCE_AND_CLIENT_PACKAGES); + } + }; + } + + private static DescribedPredicate isForbiddenTransportType() { + return new DescribedPredicate<>("are transport types forbidden inside the application") { + @Override + public boolean test(JavaClass candidate) { + return WebForbiddenTypeCatalog.TRANSPORT_TYPES_FORBIDDEN_INSIDE.contains( + candidate.getName()); + } + }; + } + + private static DescribedPredicate annotatedAsPersistence() { + return new DescribedPredicate<>("are annotated as a persistence type") { + @Override + public boolean test(JavaClass candidate) { + return WebForbiddenTypeCatalog.PERSISTENCE_TYPE_ANNOTATIONS.stream() + .anyMatch(candidate::isAnnotatedWith); + } + }; + } + + /** + * No Stable package may name an Advanced one. + * + *

The rule a feature flag cannot enforce. A flag decides whether an Advanced bean is created; + * it does nothing about a Stable class that imports an Advanced type, and one such edge makes the + * Stable platform unbuildable without the Advanced code — at which point the separation the + * design asked for exists only in the documentation. + * + *

This is the web counterpart of WS-ARCH-6 in the websocket leaf, for the same reason and with + * the same wording. + */ + public static ArchRule stableDoesNotDependOnAdvanced() { + return noClasses() + .that() + .resideInAPackage("dev.caskeleton.adapter.inbound.web..") + .and() + .resideOutsideOfPackage("dev.caskeleton.adapter.inbound.web.advanced..") + .should() + .dependOnClassesThat() + .resideInAPackage("dev.caskeleton.adapter.inbound.web.advanced..") + .as( + "WEB-ARCH-ADV: a Stable package may not name an Advanced one. A feature flag decides" + + " whether a bean is created; it does nothing about a compile-time edge") + .allowEmptyShould(true); + } + + /** Marker so an unused import of {@code classes()} does not appear; kept for rule symmetry. */ + static ArchRule everyControllerIsPublic() { + return classes() + .that() + .resideInAnyPackage("..web..controller..", "..web.controller..") + .should() + .bePublic() + .as("controllers are public") + .because("a package-private controller is not registered by component scanning"); + } +} diff --git a/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/arch/WebForbiddenTypeCatalog.java b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/arch/WebForbiddenTypeCatalog.java new file mode 100644 index 00000000..bfc0b31c --- /dev/null +++ b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/arch/WebForbiddenTypeCatalog.java @@ -0,0 +1,78 @@ +package dev.caskeleton.adapter.inbound.web.testkit.arch; + +import java.util.List; +import java.util.Set; + +/** + * The type families a controller, a DTO or an application package may not touch. + * + *

Held as package prefixes in one place rather than inlined into each rule. Two rules that name + * the same forbidden family independently drift apart, and the one that was not updated is the one + * that keeps passing — which is worse than not having it, because the suite reports coverage it + * does not have. + */ +public final class WebForbiddenTypeCatalog { + + /** + * Packages a controller may not reach directly. + * + *

Not because these libraries are bad, but because a controller that holds one of them has + * skipped the application: there is no use case to test, no transaction boundary anybody + * declared, and no port a different transport could reuse. + */ + public static final Set PERSISTENCE_AND_CLIENT_PACKAGES = + Set.of( + "org.springframework.data", + "jakarta.persistence", + "org.hibernate", + "com.mongodb", + "org.springframework.web.client", + "org.springframework.web.reactive.function.client", + "org.springframework.kafka", + "org.springframework.amqp", + "io.minio", + "software.amazon.awssdk", + "javax.sql", + "java.sql"); + + /** + * Transport types that must not appear in a domain or application package. + * + *

A use case that takes an {@code HttpServletRequest} cannot be called by the GraphQL + * transport, the WebSocket transport or a scheduled job, and cannot be unit tested without a + * container. {@code ProblemDetail} is on the list for the same reason in the other direction: an + * application that builds an HTTP error body has chosen a status, which is the transport's + * decision to make. + */ + public static final Set TRANSPORT_TYPES_FORBIDDEN_INSIDE = + Set.of( + "jakarta.servlet.http.HttpServletRequest", + "jakarta.servlet.http.HttpServletResponse", + "org.springframework.web.server.ServerWebExchange", + "org.springframework.http.ProblemDetail", + "org.springframework.http.ResponseEntity"); + + /** Annotations that must not appear on a controller. */ + public static final Set FORBIDDEN_CONTROLLER_ANNOTATIONS = + Set.of( + "org.springframework.transaction.annotation.Transactional", + "jakarta.transaction.Transactional"); + + /** Wire model annotations whose presence marks a persistence type. */ + public static final List PERSISTENCE_TYPE_ANNOTATIONS = + List.of( + "jakarta.persistence.Entity", + "jakarta.persistence.Embeddable", + "org.springframework.data.mongodb.core.mapping.Document"); + + private WebForbiddenTypeCatalog() {} + + /** Whether a package name falls inside a forbidden family. */ + public static boolean insideAny(String packageName, Set families) { + if (packageName == null) { + return false; + } + return families.stream() + .anyMatch(family -> packageName.equals(family) || packageName.startsWith(family + ".")); + } +} diff --git a/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/budget/BudgetFixtureController.java b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/budget/BudgetFixtureController.java new file mode 100644 index 00000000..bc8392f0 --- /dev/null +++ b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/budget/BudgetFixtureController.java @@ -0,0 +1,57 @@ +package dev.caskeleton.adapter.inbound.web.testkit.budget; + +import jakarta.servlet.http.HttpServletRequest; +import java.io.IOException; +import java.io.InputStream; +import java.util.concurrent.atomic.AtomicLong; +import org.springframework.boot.test.context.TestComponent; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * Routes that read a request body and produce a response of a requested size. + * + *

The echo route reads the stream itself rather than binding a {@code @RequestBody}, because the + * property under test is that the *stream* is bounded. A bound that only worked for a body Spring + * had already materialised would pass a binding-based fixture and fail in production for anything + * that streams. + */ +// @TestComponent for the same reason as the fixture applications: a plain @RestController in this +// package is scanned into the real application, publishing fixture routes in a deployment. +@RestController +@TestComponent +public class BudgetFixtureController { + + private final AtomicLong bytesRead = new AtomicLong(); + + /** Reads the body and reports how many bytes reached the handler. */ + @PostMapping(path = BudgetFixtureProtocol.ECHO_PATH, produces = MediaType.TEXT_PLAIN_VALUE) + public ResponseEntity echo(HttpServletRequest request) throws IOException { + long read = 0; + byte[] chunk = new byte[256]; + try (InputStream body = request.getInputStream()) { + int count; + while ((count = body.read(chunk)) != -1) { + read += count; + } + } + bytesRead.set(read); + return ResponseEntity.ok(Long.toString(read)); + } + + /** Produces a response of the requested size. */ + @GetMapping(path = BudgetFixtureProtocol.PRODUCE_PATH, produces = MediaType.TEXT_PLAIN_VALUE) + public ResponseEntity produce(@RequestParam int bytes) { + return ResponseEntity.ok("x".repeat(bytes)); + } + + /** How many bytes the last echo actually read. */ + @GetMapping(path = BudgetFixtureProtocol.BYTES_READ_PATH, produces = MediaType.TEXT_PLAIN_VALUE) + public String bytesRead() { + return Long.toString(bytesRead.get()); + } +} diff --git a/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/budget/BudgetFixtureProtocol.java b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/budget/BudgetFixtureProtocol.java new file mode 100644 index 00000000..f21f41ae --- /dev/null +++ b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/budget/BudgetFixtureProtocol.java @@ -0,0 +1,52 @@ +package dev.caskeleton.adapter.inbound.web.testkit.budget; + +import dev.caskeleton.adapter.inbound.web.budget.WebRequestBudget; +import java.time.Duration; + +/** + * The budget the fixture lanes enforce, and the routes they enforce it on. + * + *

Deliberately small. The platform's real bounds are megabytes, and a lane that had to push a + * megabyte over a loopback socket for every case would spend its time on the transfer rather than + * on the assertion. The behaviour under test is what happens at the boundary, not where the + * boundary is. + */ +public final class BudgetFixtureProtocol { + + private BudgetFixtureProtocol() {} + + /** The body bound the lanes test against. */ + public static final int MAX_BODY_BYTES = 1024; + + /** The response bound the lanes test against. */ + public static final int MAX_RESPONSE_BYTES = 2048; + + /** The URI bound the lanes test against. */ + public static final int MAX_URI_BYTES = 512; + + /** The query parameter bound the lanes test against. */ + public static final int MAX_QUERY_PARAMETERS = 8; + + /** Where a body is posted. */ + public static final String ECHO_PATH = "/api/v1/fixtures/budget/echo"; + + /** Where a response of a requested size is produced. */ + public static final String PRODUCE_PATH = "/api/v1/fixtures/budget/produce"; + + /** How many bytes of the last request body the handler actually read. */ + public static final String BYTES_READ_PATH = "/api/v1/fixtures/budget/bytes-read"; + + /** The budget the fixture applications install. */ + public static WebRequestBudget budget() { + return new WebRequestBudget( + MAX_URI_BYTES, + 8 * 1024, + MAX_QUERY_PARAMETERS, + MAX_BODY_BYTES, + 16, + 1000, + 8, + Duration.ofSeconds(10), + MAX_RESPONSE_BYTES); + } +} diff --git a/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/budget/HttpBudgetFixture.java b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/budget/HttpBudgetFixture.java new file mode 100644 index 00000000..76d53062 --- /dev/null +++ b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/budget/HttpBudgetFixture.java @@ -0,0 +1,151 @@ +package dev.caskeleton.adapter.inbound.web.testkit.budget; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URI; +import java.nio.charset.StandardCharsets; + +/** + * Drives the budget routes over a real socket. + * + *

Chunked transfer is available and used on purpose. A body sent with a declared {@code + * Content-Length} can be refused from the header alone, which proves the cheap check and nothing + * about the streaming one. Chunked sends the same bytes with nothing to declare, so only a meter + * that counts them can refuse it. + */ +public final class HttpBudgetFixture { + + /** + * One response, as the client saw it. + * + * @param status the HTTP status + * @param body the response body, possibly truncated + * @param contentType the response media type + * @param truncated whether the connection died before the body finished + */ + public record Response(int status, String body, String contentType, boolean truncated) { + + /** The problem code the body names, or null. */ + public String problemCode() { + int at = body.indexOf("\"code\":\""); + if (at < 0) { + return null; + } + int from = at + 8; + return body.substring(from, body.indexOf('"', from)); + } + } + + private final String baseUrl; + + /** + * A fixture against a running container. + * + * @param port the container's port + */ + public HttpBudgetFixture(int port) { + this.baseUrl = "http://localhost:" + port; + } + + /** Posts a body of the given size with a declared length. */ + public Response postBodyOfSize(int size) { + return post(BudgetFixtureProtocol.ECHO_PATH, size, false); + } + + /** Posts a body of the given size with no declared length. */ + public Response postChunkedBodyOfSize(int size) { + return post(BudgetFixtureProtocol.ECHO_PATH, size, true); + } + + /** Asks for a response of the given size. */ + public Response produceResponseOfSize(int size) { + return get(BudgetFixtureProtocol.PRODUCE_PATH + "?bytes=" + size); + } + + /** How many bytes of the last body reached the handler. */ + public long bytesReadByHandler() { + return Long.parseLong(get(BudgetFixtureProtocol.BYTES_READ_PATH).body().trim()); + } + + /** Requests a path with the given number of query parameters. */ + public Response getWithQueryParameters(int count) { + StringBuilder path = new StringBuilder(BudgetFixtureProtocol.PRODUCE_PATH + "?bytes=1"); + for (int index = 0; index < count; index++) { + path.append("&p").append(index).append("=1"); + } + return get(path.toString()); + } + + /** Requests a path of roughly the given length. */ + public Response getWithUriOfSize(int size) { + return get(BudgetFixtureProtocol.PRODUCE_PATH + "?bytes=1&pad=" + "y".repeat(size)); + } + + private Response get(String path) { + try { + HttpURLConnection connection = open(path, "GET"); + return read(connection); + } catch (IOException e) { + throw new IllegalStateException("GET " + path + " failed", e); + } + } + + private Response post(String path, int size, boolean chunked) { + try { + HttpURLConnection connection = open(path, "POST"); + connection.setDoOutput(true); + connection.setRequestProperty("Content-Type", "application/octet-stream"); + if (chunked) { + connection.setChunkedStreamingMode(256); + } else { + connection.setFixedLengthStreamingMode(size); + } + try (OutputStream out = connection.getOutputStream()) { + byte[] chunk = new byte[256]; + int remaining = size; + while (remaining > 0) { + int length = Math.min(remaining, chunk.length); + out.write(chunk, 0, length); + remaining -= length; + } + } catch (IOException refusedMidWrite) { + // The server rejected the body before the client finished sending it. That is the desired + // behaviour, not a failure: the response still carries the status and the problem. + } + return read(connection); + } catch (IOException e) { + throw new IllegalStateException("POST " + path + " failed", e); + } + } + + private HttpURLConnection open(String path, String method) throws IOException { + HttpURLConnection connection = + (HttpURLConnection) URI.create(baseUrl + path).toURL().openConnection(); + connection.setRequestMethod(method); + connection.setConnectTimeout(5_000); + connection.setReadTimeout(10_000); + return connection; + } + + private static Response read(HttpURLConnection connection) throws IOException { + try { + int status = connection.getResponseCode(); + boolean truncated = false; + String body; + try (InputStream stream = + status >= 400 ? connection.getErrorStream() : connection.getInputStream()) { + body = stream == null ? "" : new String(stream.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException cutOff) { + // The response budget was crossed after commit, so the container dropped the connection + // mid-document. A truncated read is the signal, and it is the intended one. + body = ""; + truncated = true; + } + return new Response(status, body, connection.getContentType(), truncated); + } finally { + connection.disconnect(); + } + } +} diff --git a/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/budget/ReactiveBudgetFixtureController.java b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/budget/ReactiveBudgetFixtureController.java new file mode 100644 index 00000000..386c505d --- /dev/null +++ b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/budget/ReactiveBudgetFixtureController.java @@ -0,0 +1,56 @@ +package dev.caskeleton.adapter.inbound.web.testkit.budget; + +import java.util.concurrent.atomic.AtomicLong; +import org.springframework.boot.test.context.TestComponent; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +/** + * The same routes on the reactive stack, consuming the body as a stream of buffers. + * + *

Reducing over {@code getBody()} rather than binding a value, for the same reason as the + * servlet fixture: the bound must hold for a body that is never fully in memory. + */ +// @TestComponent for the same reason as the fixture applications: a plain @RestController in this +// package is scanned into the real application, publishing fixture routes in a deployment. +@RestController +@TestComponent +public class ReactiveBudgetFixtureController { + + private final AtomicLong bytesRead = new AtomicLong(); + + /** Reads the body and reports how many bytes reached the handler. */ + @PostMapping(path = BudgetFixtureProtocol.ECHO_PATH, produces = MediaType.TEXT_PLAIN_VALUE) + public Mono> echo(ServerWebExchange exchange) { + return exchange + .getRequest() + .getBody() + .reduce( + 0L, + (total, buffer) -> { + long size = buffer.readableByteCount(); + org.springframework.core.io.buffer.DataBufferUtils.release(buffer); + return total + size; + }) + .doOnNext(bytesRead::set) + .map(read -> ResponseEntity.ok(Long.toString(read))); + } + + /** Produces a response of the requested size. */ + @GetMapping(path = BudgetFixtureProtocol.PRODUCE_PATH, produces = MediaType.TEXT_PLAIN_VALUE) + public Mono> produce(@RequestParam int bytes) { + return Mono.just(ResponseEntity.ok("x".repeat(bytes))); + } + + /** How many bytes the last echo actually read. */ + @GetMapping(path = BudgetFixtureProtocol.BYTES_READ_PATH, produces = MediaType.TEXT_PLAIN_VALUE) + public Mono bytesRead() { + return Mono.fromSupplier(() -> Long.toString(bytesRead.get())); + } +} diff --git a/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/budget/WebBudgetContract.java b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/budget/WebBudgetContract.java new file mode 100644 index 00000000..2077429a --- /dev/null +++ b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/budget/WebBudgetContract.java @@ -0,0 +1,135 @@ +package dev.caskeleton.adapter.inbound.web.testkit.budget; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.inbound.web.error.WebProblem; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * What every transport must do at a budget boundary. + * + *

The cases split along the line that matters: a bound crossed before anything was sent is a + * problem document the caller can act on, and a bound crossed after the response was committed can + * only be a broken connection. Getting the second one wrong is the dangerous half — a response + * silently cut short still looks like valid JSON if it happens to end at a brace. + */ +public abstract class WebBudgetContract { + + /** The lane's fixture. */ + protected abstract HttpBudgetFixture fixture(); + + @Test + @DisplayName("a body within the budget is served normally") + void bodyWithinBudgetIsAccepted() { + HttpBudgetFixture.Response response = + fixture().postBodyOfSize(BudgetFixtureProtocol.MAX_BODY_BYTES); + + assertThat(response.status()).isEqualTo(200); + } + + @Test + @DisplayName("a body one byte over the budget is refused with 413") + void bodyAboveLimitIsRejectedWith413() { + HttpBudgetFixture.Response response = + fixture().postBodyOfSize(BudgetFixtureProtocol.MAX_BODY_BYTES + 1); + + assertThat(response.status()).isEqualTo(413); + assertThat(response.problemCode()).isEqualTo("REQUEST_TOO_LARGE"); + assertThat(response.contentType()).contains(WebProblem.MEDIA_TYPE); + } + + @Test + @DisplayName("a chunked body over the budget is refused too") + void chunkedBodyAboveLimitIsRejected() { + // Nothing declared, so nothing to check up front. Only a meter counting the bytes as they + // arrive can refuse this, which is the point of counting rather than measuring. + HttpBudgetFixture.Response response = + fixture().postChunkedBodyOfSize(BudgetFixtureProtocol.MAX_BODY_BYTES * 4); + + assertThat(response.status()).isEqualTo(413); + assertThat(response.problemCode()).isEqualTo("REQUEST_TOO_LARGE"); + } + + @Test + @DisplayName("an oversized body never reaches the handler in full") + void oversizedBodyIsNotMaterialised() { + // The requirement the design states outright: a check that reads the body to measure it is + // the heap exhaustion it was added to prevent. The handler must never see more than the bound. + fixture().postChunkedBodyOfSize(BudgetFixtureProtocol.MAX_BODY_BYTES * 8); + + assertThat(fixture().bytesReadByHandler()) + .isLessThanOrEqualTo(BudgetFixtureProtocol.MAX_BODY_BYTES); + } + + @Test + @DisplayName("a response within the budget is served normally") + void responseWithinBudgetIsServed() { + HttpBudgetFixture.Response response = + fixture().produceResponseOfSize(BudgetFixtureProtocol.MAX_RESPONSE_BYTES / 2); + + assertThat(response.status()).isEqualTo(200); + assertThat(response.body()).hasSize(BudgetFixtureProtocol.MAX_RESPONSE_BYTES / 2); + } + + @Test + @DisplayName("an oversized response never reaches the client whole") + void oversizedResponseIsNotDeliveredWhole() { + // Either answer is correct and both are honest: refused with a problem while the response was + // still open, or cut off once it was not. What must never happen is a 200 carrying a body the + // client accepts as complete. + HttpBudgetFixture.Response response = + fixture().produceResponseOfSize(BudgetFixtureProtocol.MAX_RESPONSE_BYTES * 2); + + boolean refusedCleanly = response.status() >= 500 && response.problemCode() != null; + boolean cutOff = response.truncated() || response.body().isEmpty(); + assertThat(refusedCleanly || cutOff) + .as( + "an over-budget response must be refused or cut off, was status %d with %d bytes", + response.status(), response.body().length()) + .isTrue(); + // Not a disjunction that any short body satisfies: whatever did arrive must be inside the + // bound. Without this the case would pass for a platform that merely trimmed a few bytes. + assertThat(response.body().length()) + .isLessThanOrEqualTo(BudgetFixtureProtocol.MAX_RESPONSE_BYTES); + } + + @Test + @DisplayName("too many query parameters is a validation failure, not a size failure") + void tooManyQueryParametersIsAValidationFailure() { + // Not REQUEST_TOO_LARGE: the request is a fine size and its shape is what the profile refuses. + // Told "too large", a client would compress it and try again. + // + // 422 rather than 400 because that is what the platform's own split says and what + // ProblemCatalog pins VALIDATION_FAILED to — 400 is for a document that did not parse, and + // this one parsed perfectly. The status is read from the catalog rather than asserted as a + // literal anywhere in the production path, so there is one table and nothing to drift. + HttpBudgetFixture.Response response = + fixture().getWithQueryParameters(BudgetFixtureProtocol.MAX_QUERY_PARAMETERS + 4); + + assertThat(response.status()).isEqualTo(422); + assertThat(response.problemCode()).isEqualTo("VALIDATION_FAILED"); + } + + @Test + @DisplayName("a URI over the budget is refused") + void oversizedUriIsRejected() { + HttpBudgetFixture.Response response = + fixture().getWithUriOfSize(BudgetFixtureProtocol.MAX_URI_BYTES * 2); + + assertThat(response.status()).isEqualTo(413); + assertThat(response.problemCode()).isEqualTo("REQUEST_TOO_LARGE"); + } + + @Test + @DisplayName("a refusal says the limit and does not echo what was sent") + void refusalStatesTheLimitWithoutEchoingTheRequest() { + HttpBudgetFixture.Response response = + fixture().postBodyOfSize(BudgetFixtureProtocol.MAX_BODY_BYTES + 1); + + // The limit is public API — a client needs it to comply. The observed size is not, and a + // message that echoes the request is how a size error becomes a reflection gadget. + assertThat(response.body()).contains(Integer.toString(BudgetFixtureProtocol.MAX_BODY_BYTES)); + assertThat(response.body()).doesNotContain("observed"); + } +} diff --git a/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/contract/WebContractFixture.java b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/contract/WebContractFixture.java new file mode 100644 index 00000000..855dda79 --- /dev/null +++ b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/contract/WebContractFixture.java @@ -0,0 +1,150 @@ +package dev.caskeleton.adapter.inbound.web.testkit.contract; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.TreeSet; + +/** + * Runs the probe list against one container and records the normalized outcomes. + * + *

Recording to a file rather than comparing in memory, because the two stacks cannot be in one + * JVM: Spring Boot deduces a single application type from the classpath, so Tomcat and Reactor + * Netty are mutually exclusive within a source set. Each lane records what it saw; a test in the + * default source set reads the recordings and compares them. + * + *

That indirection is also what makes the comparison honest. An in-memory parity check would + * have to build both stacks from the same test, which is exactly the situation where a shared + * helper makes them agree by construction rather than by both being correct. + */ +public final class WebContractFixture { + + /** Where recordings are written, relative to the leaf's build directory. */ + public static final String RECORDING_DIRECTORY = "web-contract-parity"; + + /** The headers a client branches on, and therefore the ones parity covers. */ + private static final List CONTRACT_HEADERS = + List.of( + "Location", "Content-Location", "ETag", "Retry-After", "Idempotency-Replayed", "Allow"); + + private final String baseUrl; + + /** + * A fixture against a running container. + * + * @param port the container's port + */ + public WebContractFixture(int port) { + this.baseUrl = "http://localhost:" + port; + } + + /** + * Runs every probe and records what came back. + * + * @param lane the lane's name, used as the recording's file name + */ + public Map runAndRecord(String lane) { + Map outcomes = new LinkedHashMap<>(); + for (WireProbe probe : WebPlatformContractSuite.probes()) { + outcomes.put(probe.name(), send(probe)); + } + write(lane, outcomes); + return outcomes; + } + + private void write(String lane, Map outcomes) { + StringBuilder recorded = new StringBuilder(); + outcomes.forEach( + (name, outcome) -> + recorded.append(name).append('=').append(outcome.recorded()).append('\n')); + try { + Path directory = recordingDirectory(); + Files.createDirectories(directory); + Files.writeString(directory.resolve(lane + ".properties"), recorded.toString()); + } catch (IOException e) { + throw new IllegalStateException( + "the parity recording for " + lane + " could not be written", e); + } + } + + /** Where recordings live. */ + public static Path recordingDirectory() { + // Resolved from the build directory rather than a temp path, so a recording survives to be + // read by the comparison test in another source set and another Gradle task. + return Path.of(System.getProperty("web.parity.dir", "build/" + RECORDING_DIRECTORY)); + } + + private WireOutcome send(WireProbe probe) { + try { + HttpURLConnection connection = + (HttpURLConnection) URI.create(baseUrl + probe.path()).toURL().openConnection(); + connection.setRequestMethod(probe.method()); + connection.setConnectTimeout(5_000); + connection.setReadTimeout(15_000); + probe.headers().forEach(connection::setRequestProperty); + if (probe.body() != null) { + connection.setDoOutput(true); + try (OutputStream out = connection.getOutputStream()) { + out.write(probe.body().getBytes(StandardCharsets.UTF_8)); + } + } else if ("POST".equals(probe.method())) { + connection.setDoOutput(true); + connection.getOutputStream().close(); + } + try { + int status = connection.getResponseCode(); + String body; + try (InputStream stream = + status >= 400 ? connection.getErrorStream() : connection.getInputStream()) { + body = stream == null ? "" : new String(stream.readAllBytes(), StandardCharsets.UTF_8); + } + return new WireOutcome( + status, mediaTypeOf(connection), problemCodeOf(body), contractHeadersOf(connection)); + } finally { + connection.disconnect(); + } + } catch (IOException e) { + throw new IllegalStateException("probe " + probe.name() + " failed", e); + } + } + + private static String mediaTypeOf(HttpURLConnection connection) { + String contentType = connection.getContentType(); + if (contentType == null) { + return ""; + } + // Parameters stripped: one container writes "charset=UTF-8" and the other "charset=utf-8", + // and neither is a contract difference. + int semicolon = contentType.indexOf(';'); + return (semicolon < 0 ? contentType : contentType.substring(0, semicolon)).trim(); + } + + private static String problemCodeOf(String body) { + int at = body.indexOf("\"code\":\""); + if (at < 0) { + return ""; + } + int from = at + 8; + return body.substring(from, body.indexOf('"', from)); + } + + private static String contractHeadersOf(HttpURLConnection connection) { + // Names only, sorted. The values carry ids and timestamps that differ per run, and a parity + // check that compared them would be comparing entropy. + TreeSet present = new TreeSet<>(); + for (String name : CONTRACT_HEADERS) { + if (connection.getHeaderField(name) != null) { + present.add(name); + } + } + return String.join(",", present); + } +} diff --git a/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/contract/WebPlatformContractRecording.java b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/contract/WebPlatformContractRecording.java new file mode 100644 index 00000000..4e883946 --- /dev/null +++ b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/contract/WebPlatformContractRecording.java @@ -0,0 +1,71 @@ +package dev.caskeleton.adapter.inbound.web.testkit.contract; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Runs the probe list on one lane and records what the wire looked like. + * + *

Extended by each transport. The assertions here are the ones that can be made without seeing + * the other stack — that the platform answered at all, and that its problem documents are problem + * documents. The comparison between stacks is a separate test that reads the recordings, because + * the two stacks cannot be started in one JVM. + */ +public abstract class WebPlatformContractRecording { + + /** The lane's fixture. */ + protected abstract WebContractFixture fixture(); + + /** The lane's name in the recording. */ + protected abstract String laneName(); + + @Test + @DisplayName("every probe is answered and recorded") + void everyProbeIsAnsweredAndRecorded() { + Map outcomes = fixture().runAndRecord(laneName()); + + assertThat(outcomes).hasSameSizeAs(WebPlatformContractSuite.probes()); + // Nothing may reach the container's own error page. A 500 here means the platform did not + // answer, and a parity comparison of two identical 500s would report agreement. + assertThat(outcomes.values()).noneMatch(outcome -> outcome.status() >= 500); + } + + @Test + @DisplayName("every failure is a problem document with a code") + void everyFailureIsAProblemDocument() { + Map outcomes = fixture().runAndRecord(laneName()); + + outcomes.forEach( + (name, outcome) -> { + if (outcome.status() >= 400) { + assertThat(outcome.contentType()) + .as("%s answered %d without a problem document", name, outcome.status()) + .isEqualTo("application/problem+json"); + assertThat(outcome.problemCode()) + .as("%s answered %d with no code to branch on", name, outcome.status()) + .isNotEmpty(); + } + }); + } + + @Test + @DisplayName("a created resource is locatable") + void createdResourceIsLocatable() { + Map outcomes = fixture().runAndRecord(laneName()); + + assertThat(outcomes.get("create-201").status()).isEqualTo(201); + assertThat(outcomes.get("create-201").contractHeaders()).contains("Location"); + } + + @Test + @DisplayName("an accepted operation is locatable") + void acceptedOperationIsLocatable() { + Map outcomes = fixture().runAndRecord(laneName()); + + assertThat(outcomes.get("operation-accepted-202").status()).isEqualTo(202); + assertThat(outcomes.get("operation-accepted-202").contractHeaders()).contains("Location"); + } +} diff --git a/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/contract/WebPlatformContractSuite.java b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/contract/WebPlatformContractSuite.java new file mode 100644 index 00000000..44670f56 --- /dev/null +++ b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/contract/WebPlatformContractSuite.java @@ -0,0 +1,55 @@ +package dev.caskeleton.adapter.inbound.web.testkit.contract; + +import dev.caskeleton.adapter.inbound.web.testkit.operation.OperationFixtureSupport; +import java.util.List; +import java.util.Map; + +/** + * The probes whose answers must be identical on every transport. + * + *

One list, used by every lane. Two lanes that each described the contract in their own test + * class would be two descriptions, and the first divergence between them would read as a passing + * suite on both sides. + * + *

The probes span the dimensions the design names — status, headers, problem documents, + * conditional requests, idempotency, operations, cache and budgets — choosing for each the case + * where the two stacks are most likely to differ rather than the case that is easiest to write. + */ +public final class WebPlatformContractSuite { + + private WebPlatformContractSuite() {} + + /** The probes, in a fixed order so recordings from two lanes line up. */ + public static List probes() { + return List.of( + // A plain success: the baseline both stacks must agree on before anything else means + // anything. + WireProbe.postJson("create-201", "/api/v1/fixtures", "{\"name\":\"parity\"}"), + // 422 with a problem document. The 400/422 split is a platform decision, and a stack that + // let the framework answer would produce 400 here. + WireProbe.postJson("validation-422", "/api/v1/fixtures", "{\"name\":\"\"}"), + // Malformed JSON. Both stacks parse with different readers, so this is where a shared + // mapper either holds or quietly does not. + WireProbe.postJson("malformed-400", "/api/v1/fixtures", "{"), + // 404 for an operation that does not exist — and, crucially, the same 404 a caller gets + // for one belonging to somebody else. + WireProbe.get("operation-absent-404", "/api/v1/operations/op-absent"), + // A 202 receipt with a Location. The header is the whole point of the response. + WireProbe.postJson("operation-accepted-202", "/api/v1/fixtures/reports", null), + // An unsupported media type: the framework answers this one, so it is where the two + // stacks are most likely to diverge without anyone having written a line of code. + new WireProbe( + "unsupported-media-415", + "POST", + "/api/v1/fixtures", + Map.of("Content-Type", "text/plain"), + "parity"), + // A method the route does not serve. + new WireProbe("method-not-allowed-405", "DELETE", "/api/v1/fixtures", Map.of(), null)); + } + + /** The principal every probe runs as, where the fixture needs one. */ + public static String principal() { + return OperationFixtureSupport.PRINCIPAL; + } +} diff --git a/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/contract/WireOutcome.java b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/contract/WireOutcome.java new file mode 100644 index 00000000..c922466c --- /dev/null +++ b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/contract/WireOutcome.java @@ -0,0 +1,44 @@ +package dev.caskeleton.adapter.inbound.web.testkit.contract; + +import java.util.Objects; + +/** + * What a probe observed, reduced to the parts both stacks must agree on. + * + *

Reduced deliberately. A byte-for-byte comparison of two responses would fail on things that + * are allowed to differ — the {@code Server} header, header ordering, a trace id, a timestamp — and + * a parity test that fails for allowed reasons gets an exclusion added to it every week until it + * excludes the thing it was checking. + * + *

What remains is the contract: the status, the media type, the failure code, and whether the + * headers a client branches on were present. Everything a client can depend on, and nothing a + * container is entitled to vary. + * + * @param status the HTTP status + * @param contentType the response media type, without parameters + * @param problemCode the failure code when the body is a problem document, else empty + * @param contractHeaders the contract-bearing headers that were present, sorted and comma-joined + */ +public record WireOutcome( + int status, String contentType, String problemCode, String contractHeaders) { + + public WireOutcome { + Objects.requireNonNull(contentType, "contentType"); + Objects.requireNonNull(problemCode, "problemCode"); + Objects.requireNonNull(contractHeaders, "contractHeaders"); + } + + /** The recorded form: one line, stable across runs. */ + public String recorded() { + return status + "|" + contentType + "|" + problemCode + "|" + contractHeaders; + } + + /** Parses a recorded line. */ + public static WireOutcome parse(String recorded) { + String[] parts = recorded.split("\\|", -1); + if (parts.length != 4) { + throw new IllegalArgumentException("not a recorded outcome: " + recorded); + } + return new WireOutcome(Integer.parseInt(parts[0]), parts[1], parts[2], parts[3]); + } +} diff --git a/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/contract/WireProbe.java b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/contract/WireProbe.java new file mode 100644 index 00000000..a80bd489 --- /dev/null +++ b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/contract/WireProbe.java @@ -0,0 +1,39 @@ +package dev.caskeleton.adapter.inbound.web.testkit.contract; + +import java.util.Map; +import java.util.Objects; + +/** + * One request whose answer both stacks must give identically. + * + *

A value rather than a method per case, so the probe list is data that can be recorded, + * replayed and compared. Parity asserted by two hand-written test classes is parity that holds + * until somebody edits one of them. + * + * @param name what this probe is called in the recording + * @param method the HTTP method + * @param path the request path + * @param headers headers to send + * @param body the request body, or null + */ +public record WireProbe( + String name, String method, String path, Map headers, String body) { + + public WireProbe { + Objects.requireNonNull(name, "name"); + Objects.requireNonNull(method, "method"); + Objects.requireNonNull(path, "path"); + Objects.requireNonNull(headers, "headers"); + headers = Map.copyOf(headers); + } + + /** A GET with no headers. */ + public static WireProbe get(String name, String path) { + return new WireProbe(name, "GET", path, Map.of(), null); + } + + /** A POST with a JSON body. */ + public static WireProbe postJson(String name, String path, String body) { + return new WireProbe(name, "POST", path, Map.of("Content-Type", "application/json"), body); + } +} diff --git a/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/fault/FaultFixtureController.java b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/fault/FaultFixtureController.java new file mode 100644 index 00000000..135d3f0d --- /dev/null +++ b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/fault/FaultFixtureController.java @@ -0,0 +1,155 @@ +package dev.caskeleton.adapter.inbound.web.testkit.fault; + +import dev.caskeleton.adapter.inbound.web.core.ActorContext; +import dev.caskeleton.adapter.inbound.web.core.ApiMajorVersion; +import dev.caskeleton.adapter.inbound.web.core.ExternalRequestContext; +import dev.caskeleton.adapter.inbound.web.core.TenantContext; +import dev.caskeleton.adapter.inbound.web.core.WebOperationName; +import dev.caskeleton.adapter.inbound.web.core.WebRequestContext; +import dev.caskeleton.adapter.inbound.web.core.WebRequestId; +import dev.caskeleton.adapter.inbound.web.core.WebTraceId; +import dev.caskeleton.adapter.inbound.web.error.WebProblemFactory; +import dev.caskeleton.adapter.inbound.web.idempotency.DeterministicCommandEncoder; +import dev.caskeleton.adapter.inbound.web.idempotency.FingerprintHeaderPolicy; +import dev.caskeleton.adapter.inbound.web.idempotency.SemanticRequestFingerprintFactory; +import dev.caskeleton.adapter.inbound.web.idempotency.WebIdempotencyGate; +import dev.caskeleton.adapter.inbound.web.json.WebObjectMapperFactory; +import dev.caskeleton.adapter.inbound.web.mvc.idempotency.IdempotentResponseWriter; +import dev.caskeleton.adapter.inbound.web.mvc.idempotency.WebMvcIdempotentInvoker; +import dev.caskeleton.adapter.inbound.web.operation.WebOperationProfile; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.time.Clock; +import java.time.Duration; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; +import org.springframework.boot.test.context.TestComponent; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RestController; + +/** + * A servlet route that can lose its response after the write has committed. + * + *

The stall is placed relative to the idempotent invoker rather than inside it, because that is + * where the interesting boundary actually is: at {@code + * AFTER_APPLICATION_COMMIT_BEFORE_RESPONSE_HEADERS} the invoker has already returned, so the record + * is {@code COMPLETED} and the side effect has happened — and the client is about to see nothing at + * all. A stall placed inside the handler would be testing a different, easier scenario in which + * nothing was ever stored. + */ +// @TestComponent for the same reason as the fixture applications: a plain @RestController in this +// package is scanned into the real application, publishing fixture routes in a deployment. +@RestController +@TestComponent +public class FaultFixtureController { + + /** The request the fixture accepts. */ + public record Transfer(String to, int amount) {} + + /** What a successful create returns. */ + public record Receipt(String id) {} + + private final FaultFixtureStore store = new FaultFixtureStore(); + private final AtomicInteger sideEffects = new AtomicInteger(); + private final WebMvcIdempotentInvoker invoker = + new WebMvcIdempotentInvoker( + new WebIdempotencyGate( + store, + new SemanticRequestFingerprintFactory( + new DeterministicCommandEncoder(WebObjectMapperFactory.standard()), + FingerprintHeaderPolicy.standard()), + Clock.systemUTC(), + Duration.ofHours(1)), + new IdempotentResponseWriter( + WebObjectMapperFactory.standardJsonMapper(), WebProblemFactory.standard())); + + /** The idempotent create, optionally armed to lose its response. */ + @PostMapping( + path = FaultFixtureProtocol.CREATE_PATH, + consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity create( + HttpServletRequest servletRequest, + HttpServletResponse servletResponse, + @RequestBody Transfer transfer, + @RequestHeader(name = FaultFixtureProtocol.FAULT_POINT_HEADER, required = false) + String faultPoint) + throws java.io.IOException { + WebFaultPoint armed = faultPoint == null ? null : WebFaultPoint.valueOf(faultPoint); + + if (armed == WebFaultPoint.BEFORE_APPLICATION_START) { + stall(); + } + ResponseEntity response = + invoker.invoke( + servletRequest, + context(), + WebOperationProfile.create("fault.transfers.create"), + Map.of(), + transfer, + () -> { + sideEffects.incrementAndGet(); + return new Receipt("t-" + sideEffects.get()); + }, + 201); + if (armed == WebFaultPoint.AFTER_APPLICATION_COMMIT_BEFORE_RESPONSE_HEADERS) { + stall(); + } + if (armed == WebFaultPoint.AFTER_RESPONSE_HEADERS_BEFORE_BODY) { + // Written by hand rather than returned, because a ResponseEntity puts the headers and the + // body on the wire together and there is no moment between them to fail in. Committing the + // status first is what makes this point distinguishable from the one above: the client holds + // a 201 it cannot act on, which is a strictly worse position than holding nothing. + servletResponse.setStatus(response.getStatusCode().value()); + servletResponse.setContentType(MediaType.APPLICATION_JSON_VALUE); + servletResponse.flushBuffer(); + stall(); + return null; + } + return response; + } + + /** How many times the side effect ran. */ + @GetMapping(path = FaultFixtureProtocol.SIDE_EFFECTS_PATH, produces = MediaType.TEXT_PLAIN_VALUE) + public String sideEffects() { + return Integer.toString(sideEffects.get()); + } + + /** Clears the store and the counter between scenarios. */ + @DeleteMapping(FaultFixtureProtocol.SIDE_EFFECTS_PATH) + public ResponseEntity reset() { + store.clear(); + sideEffects.set(0); + return ResponseEntity.noContent().build(); + } + + private static void stall() { + try { + Thread.sleep(FaultFixtureProtocol.ARMED_STALL); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + private static WebRequestContext context() { + return new WebRequestContext( + new WebRequestId("req-fault"), + new WebTraceId("0af7651916cd43dd8448eb211c80319c"), + new WebOperationName("fault.transfers.create"), + new ApiMajorVersion(1), + ActorContext.authenticated("fixture", Set.of()), + TenantContext.none(), + Locale.ENGLISH, + java.time.Instant.now(), + java.time.Instant.now().plusSeconds(60), + new ExternalRequestContext("http", "localhost", 80, "")); + } +} diff --git a/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/fault/FaultFixtureProtocol.java b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/fault/FaultFixtureProtocol.java new file mode 100644 index 00000000..1f3d72f7 --- /dev/null +++ b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/fault/FaultFixtureProtocol.java @@ -0,0 +1,39 @@ +package dev.caskeleton.adapter.inbound.web.testkit.fault; + +import java.time.Duration; + +/** + * The wire vocabulary the fault lanes share. + * + *

The fault is armed by a request header rather than a server-side switch so that the two calls + * of a scenario — the armed first attempt and the plain retry — travel the same route through the + * same application instance. A switch would have to be flipped between them, and the flip is a + * second thing that can race the retry. + */ +public final class FaultFixtureProtocol { + + private FaultFixtureProtocol() {} + + /** Names the {@link WebFaultPoint} this request should fail at. */ + public static final String FAULT_POINT_HEADER = "X-Fixture-Fault-Point"; + + /** The idempotent create route. */ + public static final String CREATE_PATH = "/api/v1/fault/transfers"; + + /** Reports how many times the business side effect ran. */ + public static final String SIDE_EFFECTS_PATH = "/api/v1/fault/side-effects"; + + /** + * How long an armed handler stalls. + * + *

The response is lost by starving the client rather than by reaching for a container-specific + * way to reset a socket: the two lanes sever connections differently, and a lane-specific abort + * would make each lane assert its own container's behaviour instead of the platform's contract. + * From the client's side the outcome is identical — a committed write and an {@code IOException} + * where the response should have been. + */ + public static final Duration ARMED_STALL = Duration.ofSeconds(5); + + /** The client read timeout that turns {@link #ARMED_STALL} into a lost response. */ + public static final Duration CLIENT_READ_TIMEOUT = Duration.ofMillis(750); +} diff --git a/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/fault/FaultFixtureStore.java b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/fault/FaultFixtureStore.java new file mode 100644 index 00000000..6a9d8fbe --- /dev/null +++ b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/fault/FaultFixtureStore.java @@ -0,0 +1,67 @@ +package dev.caskeleton.adapter.inbound.web.testkit.fault; + +import dev.caskeleton.application.idempotency.IdempotencyRecord; +import dev.caskeleton.application.idempotency.IdempotencyScope; +import dev.caskeleton.application.idempotency.IdempotencyStatus; +import dev.caskeleton.application.idempotency.IdempotencyStorePort; +import dev.caskeleton.application.idempotency.RequestFingerprint; +import dev.caskeleton.application.idempotency.StoredResponse; +import java.time.Instant; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +/** + * The record store behind the fault fixtures. + * + *

In memory, because the property under test is what the transport does when a connection dies + * between the commit and the response — not how a row is written. A real datastore would add + * startup cost and a second failure mode to every lane without making the assertion any stronger. + * + *

{@code tryBegin} is {@code putIfAbsent} rather than get-then-put: the retry in these scenarios + * arrives while the first request may still be on the wire, so a non-atomic claim would let both + * attempts run and the lane would report a passing contract for a platform that double-executes. + */ +public final class FaultFixtureStore implements IdempotencyStorePort { + + private final Map records = new ConcurrentHashMap<>(); + + @Override + public boolean tryBegin( + IdempotencyScope scope, RequestFingerprint fingerprint, Instant expiresAt) { + return records.putIfAbsent( + scope, + new IdempotencyRecord( + scope, fingerprint, IdempotencyStatus.IN_FLIGHT, null, Instant.EPOCH, expiresAt)) + == null; + } + + @Override + public Optional find(IdempotencyScope scope, Instant now) { + return Optional.ofNullable(records.get(scope)).filter(record -> !record.isExpiredAt(now)); + } + + @Override + public void complete(IdempotencyScope scope, StoredResponse response) { + records.computeIfPresent( + scope, + (key, record) -> + new IdempotencyRecord( + record.scope(), + record.fingerprint(), + IdempotencyStatus.COMPLETED, + response, + record.createdAt(), + record.expiresAt())); + } + + @Override + public void discard(IdempotencyScope scope) { + records.remove(scope); + } + + /** Empties the store between scenarios. */ + public void clear() { + records.clear(); + } +} diff --git a/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/fault/HttpResponseLossFixture.java b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/fault/HttpResponseLossFixture.java new file mode 100644 index 00000000..69615aac --- /dev/null +++ b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/fault/HttpResponseLossFixture.java @@ -0,0 +1,128 @@ +package dev.caskeleton.adapter.inbound.web.testkit.fault; + +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.UUID; + +/** + * Drives the response-loss scenarios over a real socket against a running container. + * + *

{@link HttpURLConnection} rather than a client with connection pooling and transparent + * retries: the scenario is defined by one connection dying, and a client that silently opens a + * second one would answer the question the test is asking without the platform being involved. + * + *

Shared by both lanes so that "what the client observed" means the same thing in each. The two + * containers differ in how they behave once the client walks away; they must not differ in what the + * retry is answered with. + */ +public final class HttpResponseLossFixture implements ResponseLossFixture { + + private final String baseUrl; + + /** + * A fixture against a running container. + * + * @param port the container's port + */ + public HttpResponseLossFixture(int port) { + this.baseUrl = "http://localhost:" + port; + } + + @Override + public Scenario at(WebFaultPoint point) { + reset(); + return new HttpScenario(point, "key-" + UUID.randomUUID()); + } + + private void reset() { + try { + HttpURLConnection connection = open(FaultFixtureProtocol.SIDE_EFFECTS_PATH, "DELETE"); + connection.getResponseCode(); + connection.disconnect(); + } catch (IOException e) { + throw new IllegalStateException("the fixture container did not accept a reset", e); + } + } + + private HttpURLConnection open(String path, String method) throws IOException { + HttpURLConnection connection = + (HttpURLConnection) URI.create(baseUrl + path).toURL().openConnection(); + connection.setRequestMethod(method); + connection.setConnectTimeout(5_000); + connection.setReadTimeout((int) FaultFixtureProtocol.CLIENT_READ_TIMEOUT.toMillis()); + return connection; + } + + private final class HttpScenario implements Scenario { + + private final WebFaultPoint point; + private final String idempotencyKey; + + private HttpScenario(WebFaultPoint point, String idempotencyKey) { + this.point = point; + this.idempotencyKey = idempotencyKey; + } + + @Override + public void firstCall() throws IOException { + post(point); + } + + @Override + public Replay secondCallWithSameKey() { + try { + // The retry gets a generous read timeout. Reusing the armed one would make an ordinary + // slow reply look like the very failure this scenario is supposed to have moved past. + return post(null); + } catch (IOException e) { + throw new IllegalStateException("the retry after a lost response must not itself fail", e); + } + } + + @Override + public int sideEffectCount() { + try { + HttpURLConnection connection = open(FaultFixtureProtocol.SIDE_EFFECTS_PATH, "GET"); + connection.setReadTimeout(5_000); + try (InputStream body = connection.getInputStream()) { + return Integer.parseInt(new String(body.readAllBytes(), StandardCharsets.UTF_8).trim()); + } finally { + connection.disconnect(); + } + } catch (IOException e) { + throw new IllegalStateException("the fixture container did not report its side effects", e); + } + } + + private Replay post(WebFaultPoint armed) throws IOException { + HttpURLConnection connection = open(FaultFixtureProtocol.CREATE_PATH, "POST"); + if (armed == null) { + connection.setReadTimeout(10_000); + } + connection.setDoOutput(true); + connection.setRequestProperty("Content-Type", "application/json"); + connection.setRequestProperty("Idempotency-Key", idempotencyKey); + if (armed != null) { + connection.setRequestProperty(FaultFixtureProtocol.FAULT_POINT_HEADER, armed.name()); + } + connection + .getOutputStream() + .write("{\"to\":\"bob\",\"amount\":100}".getBytes(StandardCharsets.UTF_8)); + try { + int status = connection.getResponseCode(); + String body; + try (InputStream stream = + status >= 400 ? connection.getErrorStream() : connection.getInputStream()) { + body = stream == null ? "" : new String(stream.readAllBytes(), StandardCharsets.UTF_8); + } + boolean replayed = "true".equals(connection.getHeaderField("Idempotency-Replayed")); + return new Replay(status, body, replayed); + } finally { + connection.disconnect(); + } + } + } +} diff --git a/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/fault/IdempotencyResponseLossContract.java b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/fault/IdempotencyResponseLossContract.java new file mode 100644 index 00000000..9b6c4508 --- /dev/null +++ b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/fault/IdempotencyResponseLossContract.java @@ -0,0 +1,81 @@ +package dev.caskeleton.adapter.inbound.web.testkit.fault; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.IOException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * The guarantee both transports must give when a response is lost after the write committed. + * + *

Written once and extended by each lane, because this is precisely the property that is easy to + * hold in one stack and quietly lose in the other. A servlet container and a Netty pipeline report + * a severed connection at different moments and through different exceptions, and a lane that wrote + * its own version of this test would end up asserting its own container's behaviour rather than the + * platform's contract. + * + *

Extended rather than parameterised so that a lane which forgets to run it has an empty test + * class rather than a silently skipped case. + */ +public abstract class IdempotencyResponseLossContract { + + /** The lane's fixture. */ + protected abstract ResponseLossFixture fixture(); + + @Test + @DisplayName("a mutation that committed before the connection died is not executed twice") + void committedMutationIsNotExecutedTwiceAfterResponseLoss() { + ResponseLossFixture.Scenario scenario = + fixture().at(WebFaultPoint.AFTER_APPLICATION_COMMIT_BEFORE_RESPONSE_HEADERS); + + assertThatThrownBy(scenario::firstCall).isInstanceOf(IOException.class); + + ResponseLossFixture.Replay replay = scenario.secondCallWithSameKey(); + + assertThat(replay.status()).isEqualTo(201); + assertThat(scenario.sideEffectCount()).isEqualTo(1); + } + + @Test + @DisplayName("the recovered response is the first attempt's, not a fresh one") + void retryRecoversTheCommittedOutcome() { + ResponseLossFixture.Scenario scenario = + fixture().at(WebFaultPoint.AFTER_APPLICATION_COMMIT_BEFORE_RESPONSE_HEADERS); + assertThatThrownBy(scenario::firstCall).isInstanceOf(IOException.class); + + ResponseLossFixture.Replay replay = scenario.secondCallWithSameKey(); + + // A 201 with a fresh identifier would satisfy the previous test and still be wrong: the client + // would hold an identifier for a resource nobody else knows about. + assertThat(replay.replayed()).isTrue(); + assertThat(replay.body()).contains("\"id\""); + } + + @Test + @DisplayName("a fault before the application starts leaves nothing behind") + void faultBeforeApplicationStartHasNoSideEffect() { + ResponseLossFixture.Scenario scenario = fixture().at(WebFaultPoint.BEFORE_APPLICATION_START); + + assertThatThrownBy(scenario::firstCall).isInstanceOf(IOException.class); + + // The retry must be allowed to actually run. A platform that treated every armed key as spent + // would turn a transport hiccup into a permanent failure. + ResponseLossFixture.Replay replay = scenario.secondCallWithSameKey(); + assertThat(replay.status()).isEqualTo(201); + assertThat(scenario.sideEffectCount()).isEqualTo(1); + } + + @Test + @DisplayName("a fault after the headers but before the body still does not re-execute") + void faultAfterHeadersDoesNotReExecute() { + ResponseLossFixture.Scenario scenario = + fixture().at(WebFaultPoint.AFTER_RESPONSE_HEADERS_BEFORE_BODY); + + assertThatThrownBy(scenario::firstCall).isInstanceOf(IOException.class); + + assertThat(scenario.secondCallWithSameKey().status()).isEqualTo(201); + assertThat(scenario.sideEffectCount()).isEqualTo(1); + } +} diff --git a/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/fault/ReactiveFaultFixtureController.java b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/fault/ReactiveFaultFixtureController.java new file mode 100644 index 00000000..6b3b5296 --- /dev/null +++ b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/fault/ReactiveFaultFixtureController.java @@ -0,0 +1,153 @@ +package dev.caskeleton.adapter.inbound.web.testkit.fault; + +import dev.caskeleton.adapter.inbound.web.core.ActorContext; +import dev.caskeleton.adapter.inbound.web.core.ApiMajorVersion; +import dev.caskeleton.adapter.inbound.web.core.ExternalRequestContext; +import dev.caskeleton.adapter.inbound.web.core.TenantContext; +import dev.caskeleton.adapter.inbound.web.core.WebOperationName; +import dev.caskeleton.adapter.inbound.web.core.WebRequestContext; +import dev.caskeleton.adapter.inbound.web.core.WebRequestId; +import dev.caskeleton.adapter.inbound.web.core.WebTraceId; +import dev.caskeleton.adapter.inbound.web.error.WebProblemFactory; +import dev.caskeleton.adapter.inbound.web.idempotency.DeterministicCommandEncoder; +import dev.caskeleton.adapter.inbound.web.idempotency.FingerprintHeaderPolicy; +import dev.caskeleton.adapter.inbound.web.idempotency.SemanticRequestFingerprintFactory; +import dev.caskeleton.adapter.inbound.web.idempotency.WebIdempotencyGate; +import dev.caskeleton.adapter.inbound.web.json.WebObjectMapperFactory; +import dev.caskeleton.adapter.inbound.web.operation.WebOperationProfile; +import dev.caskeleton.adapter.inbound.web.webflux.idempotency.WebFluxIdempotentInvoker; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; +import org.springframework.boot.test.context.TestComponent; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; + +/** + * The reactive twin of {@link FaultFixtureController}, serving the same routes. + * + *

The stall is a {@code delay} on the elastic scheduler rather than {@code Thread.sleep}: a + * sleep here would park a Netty event-loop thread, and with a handful of scenarios in flight the + * lane would deadlock itself and report a platform bug that is entirely the fixture's. + */ +// @TestComponent for the same reason as the fixture applications: a plain @RestController in this +// package is scanned into the real application, publishing fixture routes in a deployment. +@RestController +@TestComponent +public class ReactiveFaultFixtureController { + + /** The request the fixture accepts. */ + public record Transfer(String to, int amount) {} + + /** What a successful create returns. */ + public record Receipt(String id) {} + + private final FaultFixtureStore store = new FaultFixtureStore(); + private final AtomicInteger sideEffects = new AtomicInteger(); + private final WebFluxIdempotentInvoker invoker = + new WebFluxIdempotentInvoker( + new WebIdempotencyGate( + store, + new SemanticRequestFingerprintFactory( + new DeterministicCommandEncoder(WebObjectMapperFactory.standard()), + FingerprintHeaderPolicy.standard()), + Clock.systemUTC(), + Duration.ofHours(1)), + WebObjectMapperFactory.standardJsonMapper(), + WebProblemFactory.standard(), + Schedulers.boundedElastic()); + + /** The idempotent create, optionally armed to lose its response. */ + @PostMapping( + path = FaultFixtureProtocol.CREATE_PATH, + consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE) + public Mono> create( + ServerWebExchange exchange, + @RequestBody Transfer transfer, + @RequestHeader(name = FaultFixtureProtocol.FAULT_POINT_HEADER, required = false) + String faultPoint) { + WebFaultPoint armed = faultPoint == null ? null : WebFaultPoint.valueOf(faultPoint); + + Mono> answer = + invoker.invoke( + exchange, + context(), + WebOperationProfile.create("fault.transfers.create"), + Map.of(), + transfer, + Mono.fromSupplier( + () -> { + sideEffects.incrementAndGet(); + return new Receipt("t-" + sideEffects.get()); + }), + 201); + + if (armed == WebFaultPoint.BEFORE_APPLICATION_START) { + return Mono.delay(FaultFixtureProtocol.ARMED_STALL).then(answer); + } + if (armed == WebFaultPoint.AFTER_APPLICATION_COMMIT_BEFORE_RESPONSE_HEADERS) { + return answer.delayElement(FaultFixtureProtocol.ARMED_STALL); + } + if (armed == WebFaultPoint.AFTER_RESPONSE_HEADERS_BEFORE_BODY) { + // Status and headers are committed to the wire, then nothing follows. The client is left + // holding a 201 it has no body for — the position an all-or-nothing view of a response + // insists is impossible. + return answer.flatMap( + response -> { + exchange.getResponse().setRawStatusCode(response.getStatusCode().value()); + exchange.getResponse().getHeaders().setContentType(MediaType.APPLICATION_JSON); + return exchange + .getResponse() + .setComplete() + .then(Mono.delay(FaultFixtureProtocol.ARMED_STALL)) + .then(Mono.empty()); + }); + } + return answer; + } + + /** How many times the side effect ran. */ + @GetMapping(path = FaultFixtureProtocol.SIDE_EFFECTS_PATH, produces = MediaType.TEXT_PLAIN_VALUE) + public Mono sideEffects() { + return Mono.fromSupplier(() -> Integer.toString(sideEffects.get())); + } + + /** Clears the store and the counter between scenarios. */ + @DeleteMapping(FaultFixtureProtocol.SIDE_EFFECTS_PATH) + public Mono> reset() { + return Mono.fromSupplier( + () -> { + store.clear(); + sideEffects.set(0); + return ResponseEntity.noContent().build(); + }); + } + + private static WebRequestContext context() { + return new WebRequestContext( + new WebRequestId("req-fault"), + new WebTraceId("0af7651916cd43dd8448eb211c80319c"), + new WebOperationName("fault.transfers.create"), + new ApiMajorVersion(1), + ActorContext.authenticated("fixture", Set.of()), + TenantContext.none(), + Locale.ENGLISH, + Instant.now(), + Instant.now().plusSeconds(60), + new ExternalRequestContext("http", "localhost", 80, "")); + } +} diff --git a/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/fault/ResponseLossFixture.java b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/fault/ResponseLossFixture.java new file mode 100644 index 00000000..95d08d19 --- /dev/null +++ b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/fault/ResponseLossFixture.java @@ -0,0 +1,46 @@ +package dev.caskeleton.adapter.inbound.web.testkit.fault; + +import java.io.IOException; + +/** + * What a transport lane must supply for the response-loss contract to run against it. + * + *

Two methods rather than a scripted sequence, because the guarantee is about what the second + * call observes after the first one was cut off — and the lanes differ in how they cut a + * connection, not in what the answer afterwards should be. + */ +public interface ResponseLossFixture { + + /** + * Arms a fault and returns the scenario to drive. + * + * @param point where this request should fail + */ + Scenario at(WebFaultPoint point); + + /** One armed request and its retry. */ + interface Scenario { + + /** + * The first call, which is expected to fail at the armed point. + * + * @throws IOException when the fault fires, which is the normal outcome + */ + void firstCall() throws IOException; + + /** The retry, sent with the same idempotency key. */ + Replay secondCallWithSameKey(); + + /** How many times the business side effect actually happened. */ + int sideEffectCount(); + } + + /** + * What the retry saw. + * + * @param status the HTTP status + * @param body the response body + * @param replayed whether the platform labelled it a replay + */ + record Replay(int status, String body, boolean replayed) {} +} diff --git a/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/fault/WebFaultInjector.java b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/fault/WebFaultInjector.java new file mode 100644 index 00000000..84bdc97e --- /dev/null +++ b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/fault/WebFaultInjector.java @@ -0,0 +1,40 @@ +package dev.caskeleton.adapter.inbound.web.testkit.fault; + +import java.io.IOException; + +/** + * Where a fixture asks whether this request should fail here. + * + *

Called at every {@link WebFaultPoint} the handler passes through, whether or not a fault is + * armed. A handler that only calls the injector when it expects a fault would be testing the + * injector rather than the handler. + */ +@FunctionalInterface +public interface WebFaultInjector { + + /** + * Fails when this point is armed, and does nothing otherwise. + * + * @param point the stage the request has reached + * @throws IOException when the fixture has armed this point + */ + void trigger(WebFaultPoint point) throws IOException; + + /** An injector that never fails, for the paths a fixture is not exercising. */ + static WebFaultInjector none() { + return point -> {}; + } + + /** + * An injector armed at exactly one point. + * + * @param armed the point that fails + */ + static WebFaultInjector at(WebFaultPoint armed) { + return point -> { + if (point == armed) { + throw new IOException("injected fault at " + armed); + } + }; + } +} diff --git a/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/fault/WebFaultPoint.java b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/fault/WebFaultPoint.java new file mode 100644 index 00000000..6641a9b6 --- /dev/null +++ b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/fault/WebFaultPoint.java @@ -0,0 +1,39 @@ +package dev.caskeleton.adapter.inbound.web.testkit.fault; + +/** + * The points in one request where a fault can be injected. + * + *

An enum rather than a boolean because "the request failed" is not one scenario. A failure + * before the application starts and a failure after it commits demand opposite recoveries: the + * first should be retried freely, the second must not re-execute anything. Code that treats them + * alike is code that double-charges somebody. + * + *

The order of the constants is the order of the request, so a lane can walk them and assert + * that the guarantee holds at every stage rather than at the one stage somebody thought of. + */ +public enum WebFaultPoint { + + /** Before the application layer is entered: nothing has happened, retry is free. */ + BEFORE_APPLICATION_START, + + /** Inside the application but before the transaction opens: still nothing durable. */ + AFTER_APPLICATION_START_BEFORE_TRANSACTION, + + /** + * After the transaction commits and before a single response byte leaves. + * + *

The one that matters. The write is durable, the client will see a connection reset, and + * nothing in the response tells it what happened. Everything about idempotency exists to make the + * retry from this state safe. + */ + AFTER_APPLICATION_COMMIT_BEFORE_RESPONSE_HEADERS, + + /** Headers are on the wire, the body is not: the client has a status it cannot trust. */ + AFTER_RESPONSE_HEADERS_BEFORE_BODY, + + /** Part of the body arrived: the client holds a truncated document. */ + AFTER_PARTIAL_BODY, + + /** The response is fully written; a fault here can no longer affect what the client saw. */ + AFTER_LOCAL_WRITE_COMPLETION +} diff --git a/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/mvc/ContractFixtureController.java b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/mvc/ContractFixtureController.java new file mode 100644 index 00000000..e102a4c6 --- /dev/null +++ b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/mvc/ContractFixtureController.java @@ -0,0 +1,60 @@ +package dev.caskeleton.adapter.inbound.web.testkit.mvc; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import java.net.URI; +import org.springframework.boot.test.context.TestComponent; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * The routes the real-container contract gate exercises. + * + *

A fixture rather than a sample: each endpoint exists to make one rule from the status contract + * observable over a socket. A 201 that must carry {@code Location}, a 204 whose body must be zero + * bytes, a GET whose HEAD must produce the same headers, and a body that must fail validation. + * + *

It lives in the test tree and is registered only by the fixture application, so nothing here + * reaches a deployment. + */ +// @TestComponent for the same reason as the fixture applications: a plain @RestController in this +// package is scanned into the real application, publishing fixture routes in a deployment. +@RestController +@TestComponent +@RequestMapping("/api/v1/fixtures") +public class ContractFixtureController { + + /** A request body with one constraint, so a 422 is reachable. */ + public record FixtureRequest(@NotBlank String name) {} + + /** The resource, returned directly rather than inside an envelope. */ + public record FixtureResponse(String id, String name) {} + + /** Creates; the new resource must be locatable. */ + @PostMapping( + consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity create(@Valid @RequestBody FixtureRequest request) { + return ResponseEntity.created(URI.create("/api/v1/fixtures/f1")) + .body(new FixtureResponse("f1", request.name())); + } + + /** Reads; the response carries a validator so HEAD parity is observable. */ + @GetMapping(path = "/{id}", produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity read(@PathVariable String id) { + return ResponseEntity.ok().eTag("\"v1\"").body(new FixtureResponse(id, "fixture")); + } + + /** Deletes; nothing may be written. */ + @DeleteMapping("/{id}") + public ResponseEntity delete(@PathVariable String id) { + return ResponseEntity.noContent().build(); + } +} diff --git a/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/mvc/WebContractAssertions.java b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/mvc/WebContractAssertions.java new file mode 100644 index 00000000..84f47ef5 --- /dev/null +++ b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/mvc/WebContractAssertions.java @@ -0,0 +1,149 @@ +package dev.caskeleton.adapter.inbound.web.testkit.mvc; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; + +/** + * The status contract as socket-level assertions, written once and run against every container. + * + *

One copy on purpose. The design's rule for the Jetty lane is that it runs the same Stable + * contract and that container-specific behaviour is never imported back into the shared core; two + * copies of these assertions is how the second one quietly diverges until "the same suite" is a + * claim rather than a fact. + * + *

Every assertion here is one only a container can break. MockMvc never writes to a socket, so + * it cannot show that a 204 carried zero bytes or that HEAD stripped a body. + */ +public final class WebContractAssertions { + + private final HttpClient client = + HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build(); + + private final int port; + + /** + * Assertions against a running container. + * + * @param port the ephemeral port the container bound to + */ + public WebContractAssertions(int port) { + this.port = port; + } + + /** 201 must say where the new resource is. */ + public void createdCarriesALocationHeader() throws IOException, InterruptedException { + HttpResponse created = post("/api/v1/fixtures", "{\"name\":\"x\"}"); + + assertThat(created.statusCode()).isEqualTo(201); + assertThat(created.headers().firstValue("Location")) + .as("without it the client is told something exists and not where") + .isPresent(); + assertThat(created.body()).contains("\"id\":\"f1\""); + } + + /** 204 must put nothing on the wire. */ + public void noContentWritesZeroBodyBytes() throws IOException, InterruptedException { + HttpResponse deleted = + client.send( + HttpRequest.newBuilder(uri("/api/v1/fixtures/f1")).DELETE().build(), + HttpResponse.BodyHandlers.ofByteArray()); + + assertThat(deleted.statusCode()).isEqualTo(204); + assertThat(deleted.body()) + .as("some intermediaries drop a 204 body and some forward it; there must be none to drop") + .isEmpty(); + } + + /** HEAD must be the GET headers with the body removed. */ + public void headProducesTheSameHeadersAsGet() throws IOException, InterruptedException { + HttpResponse get = + client.send( + HttpRequest.newBuilder(uri("/api/v1/fixtures/f1")).GET().build(), + HttpResponse.BodyHandlers.ofString()); + HttpResponse head = + client.send( + HttpRequest.newBuilder(uri("/api/v1/fixtures/f1")) + .method("HEAD", HttpRequest.BodyPublishers.noBody()) + .build(), + HttpResponse.BodyHandlers.ofByteArray()); + + assertThat(head.statusCode()).isEqualTo(get.statusCode()); + assertThat(head.headers().firstValue("ETag")) + .as("a HEAD whose headers differ from its GET defeats the only reason to send one") + .isEqualTo(get.headers().firstValue("ETag")); + assertThat(head.body()).isEmpty(); + } + + /** A rejected body must not publish the internals of the rejection. */ + public void aFailedValidationIsAnsweredAsAProblem() throws IOException, InterruptedException { + HttpResponse rejected = post("/api/v1/fixtures", "{\"name\":\" \"}"); + + assertThat(rejected.statusCode()).isBetween(400, 499); + assertThat(rejected.body()) + .as("a stack trace in an error body publishes the internals of every failure") + .doesNotContain("Exception") + .doesNotContain("dev.caskeleton"); + } + + /** A method the route does not serve is a 405. */ + public void anUnknownMethodOnAKnownRouteIsRefused() throws IOException, InterruptedException { + HttpResponse refused = + client.send( + HttpRequest.newBuilder(uri("/api/v1/fixtures/f1")) + .method("PATCH", HttpRequest.BodyPublishers.ofString("{}")) + .header("Content-Type", "application/json") + .build(), + HttpResponse.BodyHandlers.ofString()); + + assertThat(refused.statusCode()).isEqualTo(405); + } + + /** A media type the route does not read is a 415. */ + public void anUnsupportedMediaTypeIsRefused() throws IOException, InterruptedException { + HttpResponse refused = + client.send( + HttpRequest.newBuilder(uri("/api/v1/fixtures")) + .POST(HttpRequest.BodyPublishers.ofString("name=x")) + .header("Content-Type", "application/x-www-form-urlencoded") + .build(), + HttpResponse.BodyHandlers.ofString()); + + assertThat(refused.statusCode()).isEqualTo(415); + } + + /** A document that is not JSON is a 400. */ + public void malformedJsonIsRefused() throws IOException, InterruptedException { + assertThat(post("/api/v1/fixtures", "{\"name\":").statusCode()).isEqualTo(400); + } + + /** Runs every assertion, so a lane cannot silently cover a subset. */ + public void assertWholeContract() throws IOException, InterruptedException { + createdCarriesALocationHeader(); + noContentWritesZeroBodyBytes(); + headProducesTheSameHeadersAsGet(); + aFailedValidationIsAnsweredAsAProblem(); + anUnknownMethodOnAKnownRouteIsRefused(); + anUnsupportedMediaTypeIsRefused(); + malformedJsonIsRefused(); + } + + private HttpResponse post(String path, String body) + throws IOException, InterruptedException { + return client.send( + HttpRequest.newBuilder(uri(path)) + .POST(HttpRequest.BodyPublishers.ofString(body)) + .header("Content-Type", "application/json") + .build(), + HttpResponse.BodyHandlers.ofString()); + } + + private URI uri(String path) { + return URI.create("http://localhost:" + port + path); + } +} diff --git a/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/operation/HttpOperationFixture.java b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/operation/HttpOperationFixture.java new file mode 100644 index 00000000..519c6eb0 --- /dev/null +++ b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/operation/HttpOperationFixture.java @@ -0,0 +1,114 @@ +package dev.caskeleton.adapter.inbound.web.testkit.operation; + +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URI; +import java.nio.charset.StandardCharsets; + +/** + * Drives the operation routes over a real socket. + * + *

Over a socket because the assertions are about headers a client actually receives — {@code + * Location}, {@code Retry-After}, {@code Content-Location} — and a mock dispatcher can report those + * correct while the container drops or rewrites them. + */ +public final class HttpOperationFixture { + + /** + * One response, as the client saw it. + * + * @param status the HTTP status + * @param body the response body + * @param location the {@code Location} header, or null + * @param retryAfter the {@code Retry-After} header, or null + * @param contentLocation the {@code Content-Location} header, or null + */ + public record Response( + int status, String body, String location, String retryAfter, String contentLocation) { + + /** A named header, or null. */ + public String header(String name) { + return switch (name) { + case "Location" -> location; + case "Retry-After" -> retryAfter; + case "Content-Location" -> contentLocation; + default -> null; + }; + } + } + + private final String baseUrl; + + /** + * A fixture against a running container. + * + * @param port the container's port + */ + public HttpOperationFixture(int port) { + this.baseUrl = "http://localhost:" + port; + } + + /** Empties the fixture store. */ + public void reset() { + send("DELETE", "/api/v1/fixtures/operations"); + } + + /** Submits work and returns the receipt. */ + public Response submit() { + return send("POST", "/api/v1/fixtures/reports"); + } + + /** Reads one operation. */ + public Response get(String operationId) { + return send("GET", "/api/v1/operations/" + operationId); + } + + /** Cancels one operation. */ + public Response cancel(String operationId) { + return send("DELETE", "/api/v1/operations/" + operationId); + } + + /** Drives an operation into a state. */ + public void transition(String operationId, String state) { + send("POST", "/api/v1/fixtures/operations/" + operationId + "/state?to=" + state); + } + + /** The operation id a receipt's {@code Location} names. */ + public static String operationIdOf(Response receipt) { + String location = receipt.location(); + return location == null ? null : location.substring(location.lastIndexOf('/') + 1); + } + + private Response send(String method, String path) { + try { + HttpURLConnection connection = + (HttpURLConnection) URI.create(baseUrl + path).toURL().openConnection(); + connection.setRequestMethod(method); + connection.setConnectTimeout(5_000); + connection.setReadTimeout(10_000); + if ("POST".equals(method)) { + connection.setDoOutput(true); + connection.getOutputStream().close(); + } + try { + int status = connection.getResponseCode(); + String body; + try (InputStream stream = + status >= 400 ? connection.getErrorStream() : connection.getInputStream()) { + body = stream == null ? "" : new String(stream.readAllBytes(), StandardCharsets.UTF_8); + } + return new Response( + status, + body, + connection.getHeaderField("Location"), + connection.getHeaderField("Retry-After"), + connection.getHeaderField("Content-Location")); + } finally { + connection.disconnect(); + } + } catch (IOException e) { + throw new IllegalStateException(method + " " + path + " failed", e); + } + } +} diff --git a/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/operation/OperationFixtureController.java b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/operation/OperationFixtureController.java new file mode 100644 index 00000000..0b5fcb17 --- /dev/null +++ b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/operation/OperationFixtureController.java @@ -0,0 +1,84 @@ +package dev.caskeleton.adapter.inbound.web.testkit.operation; + +import dev.caskeleton.adapter.inbound.web.mvc.operation.OperationHttpController; +import dev.caskeleton.adapter.inbound.web.operationasync.OperationResponse; +import java.util.concurrent.atomic.AtomicInteger; +import org.springframework.boot.test.context.TestComponent; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * The operation routes as a real socket serves them, on the servlet stack. + * + *

It delegates to the real {@link OperationHttpController} rather than reimplementing it, so + * what the lane certifies is the shipped controller. Only the request context is supplied by the + * fixture: the contract app carries no authentication, and the resolver would otherwise produce an + * anonymous caller that the access policy correctly refuses — which would make every case a 404 and + * prove nothing. + */ +// @TestComponent for the same reason as the fixture applications: a plain @RestController in this +// package is scanned into the real application, publishing fixture routes in a deployment. +@RestController +@TestComponent +public class OperationFixtureController { + + private final OperationFixtureSupport support = new OperationFixtureSupport(); + private final OperationHttpController delegate = + new OperationHttpController(support.queryService()); + private final AtomicInteger submissions = new AtomicInteger(); + + /** Accepts work and hands back a receipt. */ + @PostMapping(path = "/api/v1/fixtures/reports", produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity submit() { + String operationId = "op-" + submissions.incrementAndGet(); + support.submit(operationId); + // 202 with a Location, never 200 with a body. The work has not happened, and a 200 would tell + // a client it had. + return ResponseEntity.accepted() + .location(OperationHttpController.locationOf(operationId)) + .build(); + } + + /** Reads one operation through the shipped controller. */ + @GetMapping( + path = OperationHttpController.BASE_PATH + "/{operationId}", + produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity get(@PathVariable String operationId) { + return delegate.get(operationId, OperationFixtureSupport.context()); + } + + /** Cancels one operation through the shipped controller. */ + @DeleteMapping(OperationHttpController.BASE_PATH + "/{operationId}") + public ResponseEntity cancel(@PathVariable String operationId) { + return delegate.cancel(operationId, OperationFixtureSupport.context()); + } + + /** Drives an operation into the state a scenario needs. */ + @PostMapping("/api/v1/fixtures/operations/{operationId}/state") + public ResponseEntity transition( + @PathVariable String operationId, @RequestParam String to) { + switch (to) { + case "RUNNING" -> support.start(operationId); + case "SUCCEEDED" -> support.succeed(operationId, "reports/" + operationId); + case "FAILED" -> + support.fail(operationId, "DEPENDENCY_TIMEOUT", "the report source timed out"); + case "OTHER_PRINCIPAL" -> support.submitForAnotherPrincipal(operationId); + default -> throw new IllegalArgumentException("unknown fixture state " + to); + } + return ResponseEntity.noContent().build(); + } + + /** Empties the store between scenarios. */ + @DeleteMapping("/api/v1/fixtures/operations") + public ResponseEntity reset() { + support.reset(); + submissions.set(0); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/operation/OperationFixtureSupport.java b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/operation/OperationFixtureSupport.java new file mode 100644 index 00000000..c99dcdfa --- /dev/null +++ b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/operation/OperationFixtureSupport.java @@ -0,0 +1,140 @@ +package dev.caskeleton.adapter.inbound.web.testkit.operation; + +import dev.caskeleton.adapter.inbound.web.core.ActorContext; +import dev.caskeleton.adapter.inbound.web.core.ApiMajorVersion; +import dev.caskeleton.adapter.inbound.web.core.ExternalRequestContext; +import dev.caskeleton.adapter.inbound.web.core.TenantContext; +import dev.caskeleton.adapter.inbound.web.core.WebOperationName; +import dev.caskeleton.adapter.inbound.web.core.WebRequestContext; +import dev.caskeleton.adapter.inbound.web.core.WebRequestId; +import dev.caskeleton.adapter.inbound.web.core.WebTraceId; +import dev.caskeleton.adapter.inbound.web.error.WebProblemFactory; +import dev.caskeleton.adapter.inbound.web.operationasync.OperationQueryService; +import dev.caskeleton.adapter.inbound.web.operationasync.OperationResourceFactory; +import dev.caskeleton.application.idempotency.RequestFingerprint; +import dev.caskeleton.application.operation.DurableOperationId; +import dev.caskeleton.application.operation.DurableOperationSubmission; +import dev.caskeleton.application.operation.OperationFailure; +import dev.caskeleton.application.operation.OperationProgressSnapshot; +import java.net.URI; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.Locale; +import java.util.Optional; +import java.util.Set; + +/** + * The store, the query service and the state transitions both operation fixtures drive. + * + *

Shared so the servlet and reactive fixtures put their operations into identical states. If + * each drove its own transitions, a difference between the two lanes' answers could be a difference + * in their fixtures rather than in the platform, which is the one thing a cross-container contract + * must not be ambiguous about. + */ +public final class OperationFixtureSupport { + + /** The principal every fixture request runs as. */ + public static final String PRINCIPAL = "fixture"; + + /** The worker every fixture transition runs as. */ + public static final String WORKER = "fixture-worker"; + + private final TestDurableOperationStore store = new TestDurableOperationStore(); + private final OperationQueryService queryService; + + /** A support over a fresh store. */ + public OperationFixtureSupport() { + this.queryService = + new OperationQueryService( + store, + new OperationResourceFactory( + WebProblemFactory.standard(), URI.create("/api/v1/"), Duration.ofSeconds(2)), + Clock.systemUTC()); + } + + /** The service the controllers are built on. */ + public OperationQueryService queryService() { + return queryService; + } + + /** Empties the store between scenarios. */ + public void reset() { + store.clear(); + } + + /** Records a PENDING operation. */ + public String submit(String operationId) { + store.submit( + new DurableOperationSubmission( + new DurableOperationId(operationId), + "reports.generate", + PRINCIPAL, + null, + new RequestFingerprint(fingerprintOf(operationId)), + "{}", + Instant.now(), + Duration.ofHours(1))); + return operationId; + } + + /** Moves an operation to RUNNING with progress. */ + public void start(String operationId) { + store.claimNext(WORKER, Duration.ofMinutes(5), Instant.now()); + store.reportProgress( + new DurableOperationId(operationId), + WORKER, + new OperationProgressSnapshot(3, Optional.of(10L), Optional.of("extracting"))); + } + + /** Moves an operation to SUCCEEDED. */ + public void succeed(String operationId, String resultReference) { + store.claimNext(WORKER, Duration.ofMinutes(5), Instant.now()); + store.succeed(new DurableOperationId(operationId), WORKER, resultReference, Instant.now()); + } + + /** Moves an operation to FAILED. */ + public void fail(String operationId, String code, String detail) { + store.claimNext(WORKER, Duration.ofMinutes(5), Instant.now()); + store.fail( + new DurableOperationId(operationId), + WORKER, + new OperationFailure(code, detail, false), + Instant.now()); + } + + /** Records an operation belonging to somebody else. */ + public void submitForAnotherPrincipal(String operationId) { + store.submit( + new DurableOperationSubmission( + new DurableOperationId(operationId), + "reports.generate", + "somebody-else", + null, + new RequestFingerprint(fingerprintOf(operationId)), + "{}", + Instant.now(), + Duration.ofHours(1))); + } + + /** The context every fixture request runs under. */ + public static WebRequestContext context() { + Instant now = Instant.now(); + return new WebRequestContext( + new WebRequestId("req-operation"), + new WebTraceId("0af7651916cd43dd8448eb211c80319c"), + new WebOperationName("operations.get"), + new ApiMajorVersion(1), + ActorContext.authenticated(PRINCIPAL, Set.of()), + TenantContext.none(), + Locale.ENGLISH, + now, + now.plusSeconds(60), + new ExternalRequestContext("http", "localhost", 80, "")); + } + + private static String fingerprintOf(String operationId) { + String hex = Integer.toHexString(operationId.hashCode()); + return (hex + "0".repeat(64)).substring(0, 64); + } +} diff --git a/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/operation/OperationHttpContract.java b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/operation/OperationHttpContract.java new file mode 100644 index 00000000..2e8295c5 --- /dev/null +++ b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/operation/OperationHttpContract.java @@ -0,0 +1,148 @@ +package dev.caskeleton.adapter.inbound.web.testkit.operation; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * The operation resource contract, asserted identically on every transport. + * + *

A client polling an operation cannot tell which container answered it, so the two stacks must + * not differ here. Writing the cases once and extending is what makes that true by construction + * rather than by two authors happening to agree. + */ +public abstract class OperationHttpContract { + + /** The lane's fixture. */ + protected abstract HttpOperationFixture fixture(); + + @BeforeEach + void resetFixture() { + fixture().reset(); + } + + @Test + @DisplayName("an accepted submission answers 202 with the operation's location") + void acceptedSubmissionHasOperationLocation() { + HttpOperationFixture.Response receipt = fixture().submit(); + + assertThat(receipt.status()).isEqualTo(202); + assertThat(receipt.header("Location")).startsWith("/api/v1/operations/"); + } + + @Test + @DisplayName("the location a receipt names is readable") + void theReceiptLocationResolves() { + // A 202 pointing at a 404 is worse than no Location at all: the client has nothing to poll and + // no way to tell that the work was ever accepted. + HttpOperationFixture.Response receipt = fixture().submit(); + + HttpOperationFixture.Response polled = + fixture().get(HttpOperationFixture.operationIdOf(receipt)); + + assertThat(polled.status()).isEqualTo(200); + assertThat(polled.body()).contains("\"status\":\"PENDING\""); + } + + @Test + @DisplayName("an unfinished operation tells the client how long to wait") + void unfinishedOperationCarriesRetryAfter() { + String operationId = HttpOperationFixture.operationIdOf(fixture().submit()); + + // Without it every client picks its own interval, and the impatient ones set the load. + assertThat(fixture().get(operationId).header("Retry-After")).isNotNull(); + } + + @Test + @DisplayName("a running operation reports its progress") + void runningOperationReportsProgress() { + String operationId = HttpOperationFixture.operationIdOf(fixture().submit()); + fixture().transition(operationId, "RUNNING"); + + HttpOperationFixture.Response polled = fixture().get(operationId); + + assertThat(polled.body()).contains("\"status\":\"RUNNING\""); + assertThat(polled.body()).contains("\"completedUnits\":3"); + assertThat(polled.body()).contains("\"phase\":\"extracting\""); + } + + @Test + @DisplayName("a succeeded operation says where the result is and stops asking to be polled") + void succeededOperationPublishesItsResult() { + String operationId = HttpOperationFixture.operationIdOf(fixture().submit()); + fixture().transition(operationId, "SUCCEEDED"); + + HttpOperationFixture.Response polled = fixture().get(operationId); + + assertThat(polled.body()).contains("\"status\":\"SUCCEEDED\""); + assertThat(polled.header("Content-Location")).contains("reports/" + operationId); + // A Retry-After on a finished operation would keep well-behaved clients polling for ever. + assertThat(polled.header("Retry-After")).isNull(); + } + + @Test + @DisplayName("a failed operation publishes a problem document, not a bare status") + void failedOperationPublishesAProblem() { + String operationId = HttpOperationFixture.operationIdOf(fixture().submit()); + fixture().transition(operationId, "FAILED"); + + HttpOperationFixture.Response polled = fixture().get(operationId); + + assertThat(polled.status()).isEqualTo(200); + assertThat(polled.body()).contains("\"status\":\"FAILED\""); + assertThat(polled.body()).contains("DEPENDENCY_TIMEOUT"); + assertThat(polled.header("Retry-After")).isNull(); + } + + @Test + @DisplayName("cancelling answers 202 and moves the operation to CANCELED") + void cancelIsAcceptedAndRecorded() { + String operationId = HttpOperationFixture.operationIdOf(fixture().submit()); + + // 202, not 204: a worker mid-flight may still finish, so only the intent is certain. + assertThat(fixture().cancel(operationId).status()).isEqualTo(202); + assertThat(fixture().get(operationId).body()).contains("\"status\":\"CANCELED\""); + } + + @Test + @DisplayName("cancelling twice is still 202 rather than a conflict") + void cancelIsIdempotent() { + String operationId = HttpOperationFixture.operationIdOf(fixture().submit()); + fixture().cancel(operationId); + + // 409 would invite a retry that can never change anything. + assertThat(fixture().cancel(operationId).status()).isEqualTo(202); + } + + @Test + @DisplayName("cancelling a finished operation does not rewrite its outcome") + void cancelNeverReversesASuccess() { + String operationId = HttpOperationFixture.operationIdOf(fixture().submit()); + fixture().transition(operationId, "SUCCEEDED"); + + fixture().cancel(operationId); + + assertThat(fixture().get(operationId).body()).contains("\"status\":\"SUCCEEDED\""); + } + + @Test + @DisplayName("an operation belonging to someone else is 404, not 403") + void anotherPrincipalsOperationIsIndistinguishableFromAbsent() { + fixture().transition("op-elsewhere", "OTHER_PRINCIPAL"); + + // 403 would confirm the operation exists, turning id enumeration into an oracle for what + // other callers are running. + assertThat(fixture().get("op-elsewhere").status()).isEqualTo(404); + assertThat(fixture().get("op-never-existed").status()).isEqualTo(404); + } + + @Test + @DisplayName("cancelling someone else's operation is 404 and changes nothing") + void anotherPrincipalsOperationCannotBeCancelled() { + fixture().transition("op-elsewhere", "OTHER_PRINCIPAL"); + + assertThat(fixture().cancel("op-elsewhere").status()).isEqualTo(404); + } +} diff --git a/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/operation/ReactiveOperationFixtureController.java b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/operation/ReactiveOperationFixtureController.java new file mode 100644 index 00000000..60982e5e --- /dev/null +++ b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/operation/ReactiveOperationFixtureController.java @@ -0,0 +1,92 @@ +package dev.caskeleton.adapter.inbound.web.testkit.operation; + +import dev.caskeleton.adapter.inbound.web.operationasync.OperationResponse; +import dev.caskeleton.adapter.inbound.web.webflux.operation.ReactiveOperationHttpController; +import java.net.URI; +import java.util.concurrent.atomic.AtomicInteger; +import org.springframework.boot.test.context.TestComponent; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; + +/** + * The same routes on the reactive stack, delegating to the shipped reactive controller. + * + *

Two fixtures rather than one because the controllers genuinely differ in return type; the + * states they serve come from the same {@link OperationFixtureSupport}, so a difference between the + * lanes is a difference in the platform. + */ +// @TestComponent for the same reason as the fixture applications: a plain @RestController in this +// package is scanned into the real application, publishing fixture routes in a deployment. +@RestController +@TestComponent +public class ReactiveOperationFixtureController { + + private final OperationFixtureSupport support = new OperationFixtureSupport(); + private final ReactiveOperationHttpController delegate = + new ReactiveOperationHttpController(support.queryService(), Schedulers.boundedElastic()); + private final AtomicInteger submissions = new AtomicInteger(); + + /** Accepts work and hands back a receipt. */ + @PostMapping(path = "/api/v1/fixtures/reports", produces = MediaType.APPLICATION_JSON_VALUE) + public Mono> submit() { + return Mono.fromSupplier( + () -> { + String operationId = "op-" + submissions.incrementAndGet(); + support.submit(operationId); + return ResponseEntity.accepted() + .location(URI.create(ReactiveOperationHttpController.BASE_PATH + "/" + operationId)) + .build(); + }); + } + + /** Reads one operation through the shipped controller. */ + @GetMapping( + path = ReactiveOperationHttpController.BASE_PATH + "/{operationId}", + produces = MediaType.APPLICATION_JSON_VALUE) + public Mono> get(@PathVariable String operationId) { + return delegate.get(operationId, OperationFixtureSupport.context()); + } + + /** Cancels one operation through the shipped controller. */ + @DeleteMapping(ReactiveOperationHttpController.BASE_PATH + "/{operationId}") + public Mono> cancel(@PathVariable String operationId) { + return delegate.cancel(operationId, OperationFixtureSupport.context()); + } + + /** Drives an operation into the state a scenario needs. */ + @PostMapping("/api/v1/fixtures/operations/{operationId}/state") + public Mono> transition( + @PathVariable String operationId, @RequestParam String to) { + return Mono.fromSupplier( + () -> { + switch (to) { + case "RUNNING" -> support.start(operationId); + case "SUCCEEDED" -> support.succeed(operationId, "reports/" + operationId); + case "FAILED" -> + support.fail(operationId, "DEPENDENCY_TIMEOUT", "the report source timed out"); + case "OTHER_PRINCIPAL" -> support.submitForAnotherPrincipal(operationId); + default -> throw new IllegalArgumentException("unknown fixture state " + to); + } + return ResponseEntity.noContent().build(); + }); + } + + /** Empties the store between scenarios. */ + @DeleteMapping("/api/v1/fixtures/operations") + public Mono> reset() { + return Mono.fromSupplier( + () -> { + support.reset(); + submissions.set(0); + return ResponseEntity.noContent().build(); + }); + } +} diff --git a/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/operation/TestDurableOperationStore.java b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/operation/TestDurableOperationStore.java new file mode 100644 index 00000000..05b86a42 --- /dev/null +++ b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/operation/TestDurableOperationStore.java @@ -0,0 +1,320 @@ +package dev.caskeleton.adapter.inbound.web.testkit.operation; + +import dev.caskeleton.application.idempotency.RequestFingerprint; +import dev.caskeleton.application.operation.DurableOperation; +import dev.caskeleton.application.operation.DurableOperationId; +import dev.caskeleton.application.operation.DurableOperationState; +import dev.caskeleton.application.operation.DurableOperationStorePort; +import dev.caskeleton.application.operation.DurableOperationSubmission; +import dev.caskeleton.application.operation.OperationFailure; +import dev.caskeleton.application.operation.OperationLease; +import dev.caskeleton.application.operation.OperationProgressSnapshot; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +/** + * A store with the semantics the JPA adapter must also have. + * + *

Written to the same rules rather than to whatever makes the tests pass: a submission scope + * that already exists returns the existing operation, a lease is checked on every write, and a + * terminal operation is never reopened. A fake that was laxer than the adapter would let the + * transport tests certify behaviour the real store refuses. + * + *

A near-twin of the one in {@code application-core}'s tests, and deliberately not shared: this + * leaf cannot see another leaf's test sources, and publishing a testkit from {@code + * application-core} purely to share a fake would put a test artefact on the dependency graph of + * every consumer. The duplication is bounded and visible; the alternative is not. + */ +public final class TestDurableOperationStore implements DurableOperationStorePort { + + private record Scope(String tenant, String principal, String operationName, String hash) {} + + private final Map byId = new ConcurrentHashMap<>(); + private final Map byScope = new ConcurrentHashMap<>(); + private final Map payloads = new ConcurrentHashMap<>(); + + @Override + public DurableOperation submit(DurableOperationSubmission submission) { + Scope scope = + new Scope( + submission.tenantId() == null ? "" : submission.tenantId(), + submission.principal(), + submission.operationName(), + submission.fingerprint().hex()); + DurableOperationId existing = byScope.putIfAbsent(scope, submission.operationId()); + if (existing != null) { + return byId.get(existing); + } + DurableOperation operation = + new DurableOperation( + submission.operationId(), + submission.operationName(), + submission.principal(), + submission.tenantId(), + DurableOperationState.PENDING, + submission.submittedAt(), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty(), + submission.expiresAt()); + byId.put(submission.operationId(), operation); + payloads.put(submission.operationId(), submission.payload()); + return operation; + } + + @Override + public Optional find( + DurableOperationId operationId, String principal, Instant now) { + return Optional.ofNullable(byId.get(operationId)) + .filter(operation -> operation.principal().equals(principal)); + } + + @Override + public Optional claimNext( + String workerId, Duration leaseDuration, Instant now) { + return byId.values().stream() + .filter( + operation -> + operation.state() == DurableOperationState.PENDING + || (operation.state() == DurableOperationState.RUNNING + && operation.leaseExpiredAt(now))) + .filter(operation -> now.isBefore(operation.expiresAt())) + .min(Comparator.comparing(DurableOperation::submittedAt)) + .map( + operation -> { + DurableOperation claimed = + replace( + operation, + DurableOperationState.RUNNING, + Optional.of(operation.startedAt().orElse(now)), + Optional.empty(), + operation.progress(), + operation.resultReference(), + Optional.empty(), + Optional.of(new OperationLease(workerId, now, now.plus(leaseDuration)))); + byId.put(claimed.operationId(), claimed); + return new DurableOperationSubmission( + claimed.operationId(), + claimed.operationName(), + claimed.principal(), + claimed.tenantId(), + new RequestFingerprint("0".repeat(64)), + payloads.getOrDefault(claimed.operationId(), "{}"), + claimed.submittedAt(), + Duration.between(claimed.submittedAt(), claimed.expiresAt())); + }); + } + + @Override + public boolean heartbeat( + DurableOperationId operationId, String workerId, Duration leaseDuration, Instant now) { + DurableOperation operation = byId.get(operationId); + if (!holdsLease(operation, workerId, now)) { + return false; + } + byId.put( + operationId, + replace( + operation, + operation.state(), + operation.startedAt(), + operation.completedAt(), + operation.progress(), + operation.resultReference(), + Optional.empty(), + operation.lease().map(lease -> lease.extendedTo(now.plus(leaseDuration))))); + return true; + } + + @Override + public boolean reportProgress( + DurableOperationId operationId, String workerId, OperationProgressSnapshot progress) { + DurableOperation operation = byId.get(operationId); + if (operation == null || !ownedBy(operation, workerId)) { + return false; + } + byId.put( + operationId, + replace( + operation, + operation.state(), + operation.startedAt(), + operation.completedAt(), + Optional.of(progress), + operation.resultReference(), + Optional.empty(), + operation.lease())); + return true; + } + + @Override + public boolean succeed( + DurableOperationId operationId, + String workerId, + String resultReference, + Instant completedAt) { + DurableOperation operation = byId.get(operationId); + if (operation == null || !ownedBy(operation, workerId)) { + return false; + } + byId.put( + operationId, + replace( + operation, + DurableOperationState.SUCCEEDED, + operation.startedAt(), + Optional.of(completedAt), + operation.progress(), + Optional.of(resultReference), + Optional.empty(), + Optional.empty())); + return true; + } + + @Override + public boolean fail( + DurableOperationId operationId, + String workerId, + OperationFailure failure, + Instant completedAt) { + DurableOperation operation = byId.get(operationId); + if (operation == null || !ownedBy(operation, workerId)) { + return false; + } + byId.put( + operationId, + replace( + operation, + DurableOperationState.FAILED, + operation.startedAt(), + Optional.of(completedAt), + operation.progress(), + Optional.empty(), + Optional.of(failure), + Optional.empty())); + return true; + } + + @Override + public boolean cancel(DurableOperationId operationId, String principal, Instant canceledAt) { + DurableOperation operation = byId.get(operationId); + if (operation == null + || !operation.principal().equals(principal) + || !operation.state().cancellable()) { + return false; + } + byId.put( + operationId, + replace( + operation, + DurableOperationState.CANCELED, + operation.startedAt(), + Optional.of(canceledAt), + operation.progress(), + operation.resultReference(), + Optional.empty(), + Optional.empty())); + return true; + } + + @Override + public List reclaimExpiredLeases(Instant now) { + List reclaimed = new ArrayList<>(); + byId.forEach( + (id, operation) -> { + if (operation.state() == DurableOperationState.RUNNING && operation.leaseExpiredAt(now)) { + byId.put( + id, + replace( + operation, + DurableOperationState.PENDING, + operation.startedAt(), + Optional.empty(), + operation.progress(), + operation.resultReference(), + Optional.empty(), + Optional.empty())); + reclaimed.add(id); + } + }); + return List.copyOf(reclaimed); + } + + @Override + public List expireStaleOperations(Instant now) { + List expired = new ArrayList<>(); + byId.forEach( + (id, operation) -> { + if (!operation.state().terminal() && !now.isBefore(operation.expiresAt())) { + byId.put( + id, + replace( + operation, + DurableOperationState.EXPIRED, + operation.startedAt(), + Optional.of(now), + operation.progress(), + operation.resultReference(), + Optional.empty(), + Optional.empty())); + expired.add(id); + } + }); + return List.copyOf(expired); + } + + /** How many operations exist. */ + public int size() { + return byId.size(); + } + + /** Drops everything between scenarios. */ + public void clear() { + byId.clear(); + byScope.clear(); + payloads.clear(); + } + + private static boolean holdsLease(DurableOperation operation, String workerId, Instant now) { + return operation != null && ownedBy(operation, workerId) && !operation.leaseExpiredAt(now); + } + + private static boolean ownedBy(DurableOperation operation, String workerId) { + return operation.state() == DurableOperationState.RUNNING + && operation.lease().map(lease -> lease.workerId().equals(workerId)).orElse(false); + } + + private static DurableOperation replace( + DurableOperation operation, + DurableOperationState state, + Optional startedAt, + Optional completedAt, + Optional progress, + Optional resultReference, + Optional failure, + Optional lease) { + return new DurableOperation( + operation.operationId(), + operation.operationName(), + operation.principal(), + operation.tenantId(), + state, + operation.submittedAt(), + startedAt, + completedAt, + progress, + resultReference, + failure, + lease, + operation.expiresAt()); + } +} diff --git a/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/order/HttpPipelineFixture.java b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/order/HttpPipelineFixture.java new file mode 100644 index 00000000..20507f10 --- /dev/null +++ b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/order/HttpPipelineFixture.java @@ -0,0 +1,83 @@ +package dev.caskeleton.adapter.inbound.web.testkit.order; + +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.List; + +/** + * Drives the pipeline fixture over a real socket and reads back what ran. + * + *

Real, because the async case only exists in a real container: MockMvc resolves a {@code + * Callable} without ever performing the ASYNC redispatch, so the duplicate-observation hazard the + * whole fixture is built around cannot occur there. + */ +public final class HttpPipelineFixture { + + private final String baseUrl; + + /** + * A fixture against a running container. + * + * @param port the container's port + */ + public HttpPipelineFixture(int port) { + this.baseUrl = "http://localhost:" + port; + } + + /** Clears the recorder. */ + public void reset() { + send("POST", PipelineOrderFixtureController.RESET_PATH, null); + } + + /** Runs one synchronous request. */ + public void callSync() { + send("POST", PipelineOrderFixtureController.SYNC_PATH, "{\"name\":\"pipeline\"}"); + } + + /** Runs one asynchronous request. */ + public void callAsync() { + send("POST", PipelineOrderFixtureController.ASYNC_PATH, "{\"name\":\"pipeline\"}"); + } + + /** The stages observed since the last reset. */ + public List observedStages() { + String body = send("GET", PipelineOrderFixtureController.OBSERVED_PATH, null); + if (body.isBlank()) { + return List.of(); + } + return Arrays.stream(body.split(",")).map(WebPipelineStage::valueOf).toList(); + } + + private String send(String method, String path, String body) { + try { + HttpURLConnection connection = + (HttpURLConnection) URI.create(baseUrl + path).toURL().openConnection(); + connection.setRequestMethod(method); + connection.setConnectTimeout(5_000); + connection.setReadTimeout(10_000); + if (body != null) { + connection.setDoOutput(true); + connection.setRequestProperty("Content-Type", "application/json"); + connection.getOutputStream().write(body.getBytes(StandardCharsets.UTF_8)); + } else if ("POST".equals(method)) { + connection.setDoOutput(true); + connection.getOutputStream().close(); + } + try { + int status = connection.getResponseCode(); + try (InputStream stream = + status >= 400 ? connection.getErrorStream() : connection.getInputStream()) { + return stream == null ? "" : new String(stream.readAllBytes(), StandardCharsets.UTF_8); + } + } finally { + connection.disconnect(); + } + } catch (IOException e) { + throw new IllegalStateException(method + " " + path + " failed", e); + } + } +} diff --git a/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/order/PipelineOrderFixtureController.java b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/order/PipelineOrderFixtureController.java new file mode 100644 index 00000000..6e5a9b8b --- /dev/null +++ b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/order/PipelineOrderFixtureController.java @@ -0,0 +1,90 @@ +package dev.caskeleton.adapter.inbound.web.testkit.order; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import java.util.List; +import java.util.concurrent.Callable; +import org.springframework.boot.test.context.TestComponent; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +/** + * A synchronous route, an async route, and the recorder's read-back. + * + *

The synchronous route takes a validated body so that binding is a real stage rather than a + * recorded assertion: the recorder is written from inside the controller, which can only run after + * the body bound. + */ +// @TestComponent for the same reason as the fixture applications: a plain @RestController in this +// package is scanned into the real application, publishing fixture routes in a deployment. +@RestController +@TestComponent +public class PipelineOrderFixtureController { + + /** Where the pipeline stages are read back. */ + public static final String OBSERVED_PATH = "/api/v1/fixtures/pipeline/observed"; + + /** A synchronous route. */ + public static final String SYNC_PATH = "/api/v1/fixtures/pipeline/sync"; + + /** A route that returns a {@code Callable} and is therefore redispatched. */ + public static final String ASYNC_PATH = "/api/v1/fixtures/pipeline/async"; + + /** Clears the recorder. */ + public static final String RESET_PATH = "/api/v1/fixtures/pipeline/reset"; + + /** A body with one constraint, so binding and validation genuinely happen. */ + public record PipelineRequest(@NotBlank String name) {} + + private final WebPipelineRecorder recorder; + + /** + * A controller over the recorder. + * + * @param recorder where stages are written + */ + public PipelineOrderFixtureController(WebPipelineRecorder recorder) { + this.recorder = recorder; + } + + /** Runs synchronously. */ + @PostMapping( + path = SYNC_PATH, + consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE) + public String sync(@Valid @RequestBody PipelineRequest request) { + recorder.record(WebPipelineStage.BINDING_VALIDATION); + recorder.record(WebPipelineStage.CONTROLLER); + return "\"" + request.name() + "\""; + } + + /** Runs on another thread and is redispatched when it finishes. */ + @PostMapping( + path = ASYNC_PATH, + consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE) + public Callable async(@Valid @RequestBody PipelineRequest request) { + recorder.record(WebPipelineStage.BINDING_VALIDATION); + return () -> { + recorder.record(WebPipelineStage.CONTROLLER); + return "\"" + request.name() + "\""; + }; + } + + /** The stages observed so far. */ + @GetMapping(path = OBSERVED_PATH, produces = MediaType.TEXT_PLAIN_VALUE) + public String observed() { + List stages = recorder.observed(); + return String.join(",", stages.stream().map(Enum::name).toList()); + } + + /** Clears the recorder. */ + @PostMapping(RESET_PATH) + public String reset() { + recorder.reset(); + return ""; + } +} diff --git a/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/order/WebPipelineOrderContract.java b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/order/WebPipelineOrderContract.java new file mode 100644 index 00000000..4dbd4dd5 --- /dev/null +++ b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/order/WebPipelineOrderContract.java @@ -0,0 +1,155 @@ +package dev.caskeleton.adapter.inbound.web.testkit.order; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * The order the pipeline must run in, and the number of times each stage may run. + * + *

Asserted end to end because ordering is the one property that unit tests structurally cannot + * check: every stage passes in isolation regardless of where it sits, and the cost of a wrong order + * shows up only as a rate limiter throttling a load balancer, a preflight answered 401, or two + * access-log lines for one request. + */ +public abstract class WebPipelineOrderContract { + + /** The lane's fixture. */ + protected abstract HttpPipelineFixture fixture(); + + @BeforeEach + void resetRecorder() { + fixture().reset(); + } + + @Test + @DisplayName("a synchronous request runs the stages in semantic order") + void stagesFollowSemanticOrder() { + fixture().callSync(); + + assertThat(fixture().observedStages()) + .containsExactly( + WebPipelineStage.FORWARDED_NORMALIZATION, + WebPipelineStage.CORS, + WebPipelineStage.TRACE, + WebPipelineStage.AUTHENTICATION, + WebPipelineStage.GLOBAL_ADMISSION, + WebPipelineStage.ROUTE_SELECTED, + WebPipelineStage.ROUTE_POLICY, + WebPipelineStage.BINDING_VALIDATION, + WebPipelineStage.CONTROLLER, + WebPipelineStage.RESPONSE_OR_PROBLEM, + WebPipelineStage.OBSERVATION); + } + + @Test + @DisplayName("forwarded normalization precedes everything that reads the client address") + void forwardedNormalizationIsFirst() { + // Anything before it decides on the proxy's address instead of the client's — including the + // rate limiter, which then throttles the load balancer and nobody else. + fixture().callSync(); + + assertThat(fixture().observedStages().get(0)) + .isEqualTo(WebPipelineStage.FORWARDED_NORMALIZATION); + } + + @Test + @DisplayName("CORS precedes authentication") + void corsPrecedesAuthentication() { + // A preflight carries no credentials by design. Authenticating first answers it 401 and the + // browser reports a CORS failure for an endpoint that works perfectly. + fixture().callSync(); + List stages = fixture().observedStages(); + + assertThat(stages.indexOf(WebPipelineStage.CORS)) + .isLessThan(stages.indexOf(WebPipelineStage.AUTHENTICATION)); + } + + @Test + @DisplayName("trace precedes authentication") + void tracePrecedesAuthentication() { + // Otherwise the failed-login attempts have no correlation id, and they are the ones most worth + // correlating. + fixture().callSync(); + List stages = fixture().observedStages(); + + assertThat(stages.indexOf(WebPipelineStage.TRACE)) + .isLessThan(stages.indexOf(WebPipelineStage.AUTHENTICATION)); + } + + @Test + @DisplayName("global admission precedes route selection") + void globalAdmissionPrecedesRouteSelection() { + // Shedding load must not depend on the routing table: under overload the cheapest thing the + // service can do would otherwise also be the thing it cannot do without doing the work first. + fixture().callSync(); + List stages = fixture().observedStages(); + + assertThat(stages.indexOf(WebPipelineStage.GLOBAL_ADMISSION)) + .isLessThan(stages.indexOf(WebPipelineStage.ROUTE_SELECTED)); + } + + @Test + @DisplayName("route policy follows route selection and precedes binding") + void routePolicySitsBetweenSelectionAndBinding() { + fixture().callSync(); + List stages = fixture().observedStages(); + + // A per-route budget is not knowable before the route is; and a request that will be refused + // anyway should not have its body parsed first. + assertThat(stages.indexOf(WebPipelineStage.ROUTE_SELECTED)) + .isLessThan(stages.indexOf(WebPipelineStage.ROUTE_POLICY)); + assertThat(stages.indexOf(WebPipelineStage.ROUTE_POLICY)) + .isLessThan(stages.indexOf(WebPipelineStage.BINDING_VALIDATION)); + } + + @Test + @DisplayName("observation is last") + void observationIsLast() { + fixture().callSync(); + List stages = fixture().observedStages(); + + assertThat(stages.get(stages.size() - 1)).isEqualTo(WebPipelineStage.OBSERVATION); + } + + @Test + @DisplayName("an async request observes completion exactly once") + void asyncRequestObservesCompletionOnce() { + // The case this whole fixture exists for. An async request is dispatched through the filter + // chain twice, so a completion recorded without checking whether the response is actually + // finished produces two access-log lines and two metric observations for one request — and + // every percentile computed from them is quietly wrong. + fixture().callAsync(); + + List stages = fixture().observedStages(); + assertThat(stages).filteredOn(WebPipelineStage.OBSERVATION::equals).hasSize(1); + assertThat(stages).filteredOn(WebPipelineStage.RESPONSE_OR_PROBLEM::equals).hasSize(1); + } + + @Test + @DisplayName("an async request still runs the controller after binding") + void asyncRequestKeepsTheSemanticOrder() { + fixture().callAsync(); + List stages = fixture().observedStages(); + + assertThat(stages.indexOf(WebPipelineStage.BINDING_VALIDATION)) + .isLessThan(stages.indexOf(WebPipelineStage.CONTROLLER)); + assertThat(stages.indexOf(WebPipelineStage.CONTROLLER)) + .isLessThan(stages.indexOf(WebPipelineStage.OBSERVATION)); + } + + @Test + @DisplayName("an async request selects its route exactly once") + void asyncRequestSelectsTheRouteOnce() { + // The redispatch runs handler mapping again. A route-policy stage that re-runs would charge a + // second idempotency claim or a second quota for one logical request. + fixture().callAsync(); + List stages = fixture().observedStages(); + + assertThat(stages).filteredOn(WebPipelineStage.ROUTE_SELECTED::equals).hasSize(1); + assertThat(stages).filteredOn(WebPipelineStage.ROUTE_POLICY::equals).hasSize(1); + } +} diff --git a/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/order/WebPipelineRecorder.java b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/order/WebPipelineRecorder.java new file mode 100644 index 00000000..7e93d5e5 --- /dev/null +++ b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/order/WebPipelineRecorder.java @@ -0,0 +1,46 @@ +package dev.caskeleton.adapter.inbound.web.testkit.order; + +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Records the stages one logical request passed through. + * + *

It counts as well as orders, because the failure it is most likely to catch is not a stage in + * the wrong place but a stage that ran twice. On the servlet stack an async request is dispatched + * through the whole filter chain a second time when the result is ready, so a filter that logs + * completion without checking the dispatcher type produces two access-log lines and two metric + * observations for one request. Every latency percentile computed from that data is then wrong, and + * nothing about it looks wrong. + */ +public final class WebPipelineRecorder { + + private final List ordered = new ArrayList<>(); + private final Map counts = new EnumMap<>(WebPipelineStage.class); + + /** Records that a stage ran. */ + public synchronized void record(WebPipelineStage stage) { + ordered.add(stage); + counts.computeIfAbsent(stage, key -> new AtomicInteger()).incrementAndGet(); + } + + /** The stages, in the order they ran. */ + public synchronized List observed() { + return List.copyOf(ordered); + } + + /** How many times a stage ran. */ + public synchronized int countOf(WebPipelineStage stage) { + AtomicInteger count = counts.get(stage); + return count == null ? 0 : count.get(); + } + + /** Forgets everything, between scenarios. */ + public synchronized void reset() { + ordered.clear(); + counts.clear(); + } +} diff --git a/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/order/WebPipelineStage.java b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/order/WebPipelineStage.java new file mode 100644 index 00000000..7593b562 --- /dev/null +++ b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/order/WebPipelineStage.java @@ -0,0 +1,60 @@ +package dev.caskeleton.adapter.inbound.web.testkit.order; + +/** + * The stages a request passes through, in the order they must happen. + * + *

The order is not a preference. Each pair below is a rule that costs something specific when it + * is inverted, and none of the inversions is visible from a single successful request: + * + *

    + *
  • Forwarded normalization before everything, or every stage after it decides on the proxy's + * address instead of the client's — including the rate limiter, which then throttles the load + * balancer. + *
  • CORS before authentication, or a preflight — which carries no credentials, by design — is + * answered 401 and the browser reports a CORS failure for an endpoint that works. + *
  • Trace before authentication, or the failed-login attempts have no correlation id and are + * the ones you most want to correlate. + *
  • Global admission before route selection: shedding load must not require the routing table, + * or the cheapest thing the service can do under overload is also the thing it cannot do. + *
  • Route policy after route selection, because a per-route budget is not knowable before the + * route is. + *
  • Binding and validation after route policy, so a request that will be refused anyway is not + * parsed first. + *
  • Observation last and exactly once, so one logical request is one access-log line. + *
+ */ +public enum WebPipelineStage { + + /** Forwarded headers resolved into the real client address and scheme. */ + FORWARDED_NORMALIZATION, + + /** Preflight answered, or the response's cross-origin headers decided. */ + CORS, + + /** Correlation identifiers established. */ + TRACE, + + /** The caller identified. */ + AUTHENTICATION, + + /** Load shedding, before the routing table is consulted. */ + GLOBAL_ADMISSION, + + /** The handler chosen. */ + ROUTE_SELECTED, + + /** Per-route budget, quota, idempotency and preconditions. */ + ROUTE_POLICY, + + /** The body bound and validated. */ + BINDING_VALIDATION, + + /** The handler run. */ + CONTROLLER, + + /** The response or the problem document written. */ + RESPONSE_OR_PROBLEM, + + /** Metrics and the access log. */ + OBSERVATION +} diff --git a/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/performance/GracefulShutdownProbe.java b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/performance/GracefulShutdownProbe.java new file mode 100644 index 00000000..b5d327d9 --- /dev/null +++ b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/performance/GracefulShutdownProbe.java @@ -0,0 +1,66 @@ +package dev.caskeleton.adapter.inbound.web.testkit.performance; + +import java.time.Duration; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import org.springframework.boot.web.server.WebServer; + +/** + * Drives a graceful shutdown and refuses to wait for ever. + * + *

Bounded because unbounded is the actual failure mode. {@code shutDownGracefully} waits for + * in-flight work, and on Reactor Netty a connection that is open but idle counts — so calling it + * with a lingering keep-alive connection never returns. In a test that manifests as a hung build + * with no output; in a deployment it manifests as a rolling restart that stalls with half the fleet + * drained and no error anywhere. + * + *

The probe therefore measures rather than assumes, and reports "did not finish within the grace + * period" as a result the caller can assert on. + */ +public final class GracefulShutdownProbe { + + private GracefulShutdownProbe() {} + + /** + * What a shutdown attempt did. + * + * @param finishedGracefully whether the server reported completion within the grace period + * @param took how long it took, capped at the grace period + */ + public record Outcome(boolean finishedGracefully, Duration took) {} + + /** + * Asks a server to stop and waits at most {@code grace}. + * + * @param server the server to stop + * @param grace the longest a rolling deploy is willing to wait for one instance + */ + public static Outcome shutDown(WebServer server, Duration grace) { + CountDownLatch finished = new CountDownLatch(1); + AtomicReference result = new AtomicReference<>(); + long startedAt = System.nanoTime(); + + // The callback runs on the server's own shutdown thread. Calling stop() before it fires is + // what turns a graceful shutdown into an abrupt one, so the wait comes first. + server.shutDownGracefully( + outcome -> { + result.set(outcome); + finished.countDown(); + }); + + boolean completed; + try { + completed = finished.await(grace.toMillis(), TimeUnit.MILLISECONDS); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + completed = false; + } + Duration took = Duration.ofNanos(System.nanoTime() - startedAt); + + // Stopped either way. A server that did not drain within the grace period is stopped anyway — + // that is what a deployment does, and leaving it running would strand the test JVM. + server.stop(); + return new Outcome(completed && result.get() != null, took); + } +} diff --git a/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/performance/WebLoadAndShutdownContract.java b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/performance/WebLoadAndShutdownContract.java new file mode 100644 index 00000000..3e63e769 --- /dev/null +++ b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/performance/WebLoadAndShutdownContract.java @@ -0,0 +1,119 @@ +package dev.caskeleton.adapter.inbound.web.testkit.performance; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.net.Socket; +import java.time.Duration; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * What every transport must survive, and what it must give up gracefully. + * + *

The bounds are shape assertions, not scale ones. This runs on developer machines and on CI + * runners whose capacity nobody controls, so a threshold tuned to a production box would fail for + * reasons unrelated to the change under review — and a gate that fails for unrelated reasons is one + * people rerun until it passes. What holds on any hardware is that a tail is a tail rather than a + * cliff, that shed load is shed rather than queued, and that neither heap nor connections grow + * without bound. + */ +@Tag("web-performance") +// Every case is bounded. A load gate that hangs is worse than one that fails: CI reports nothing, +// the run is killed by an outer timeout, and the result is indistinguishable from infrastructure +// flakiness. The first draft of this contract hung the reactive lane for ten minutes and produced +// no output at all. +@org.junit.jupiter.api.Timeout(value = 120, unit = java.util.concurrent.TimeUnit.SECONDS) +public abstract class WebLoadAndShutdownContract { + + /** The bound peak heap must stay inside during the standard profile. */ + protected static final long MAX_HEAP_BYTES = 512L * 1024 * 1024; + + /** The bound p99 must stay inside during the standard profile. */ + protected static final Duration MAX_P99 = Duration.ofSeconds(2); + + /** The lane's fixture. */ + protected abstract WebLoadFixture fixture(); + + /** The lane's server, for the shutdown case. */ + protected abstract org.springframework.boot.web.server.WebServer webServer(); + + /** A path the lane serves cheaply. */ + protected String loadPath() { + return "/api/v1/fixtures/f1"; + } + + @Test + @DisplayName("the standard profile stays inside its declared resource bounds") + void resultsStayInsideDeclaredResourceBounds() { + WebLoadResult result = fixture().run(loadPath(), 600, 64); + + assertThat(result.failed()) + .as("shedding is not failing; a 5xx under load is the platform not coping") + .isZero(); + assertThat(result.maxHeapBytes()).isLessThanOrEqualTo(MAX_HEAP_BYTES); + assertThat(result.p99()).isLessThan(MAX_P99); + assertThat(result.completed() + result.rejected()).isEqualTo(600); + } + + @Test + @DisplayName("the tail is a tail, not a cliff") + void latencyTailIsBounded() { + // The property an average would hide entirely. A p99 two orders of magnitude past the median + // means the queue is doing something other than smoothing bursts — and the callers in that + // 1% are the ones every capacity decision is about. + WebLoadResult result = fixture().run(loadPath(), 400, 32); + + assertThat(result.p50()).isLessThan(MAX_P99); + assertThat(result.p99()).isLessThan(MAX_P99); + assertThat(result.throughputPerSecond()).isGreaterThan(1.0); + } + + @Test + @DisplayName("slow clients do not exhaust the server") + void slowClientsDoNotExhaustTheServer() { + // Connections that send a partial request and stop. Each occupies whatever the server + // allocates per connection until a header timeout fires, which is how a handful of clients + // take down a thread-per-request pool. + List slow = fixture().openSlowClients(64); + try { + // Asserted first, because the interesting assertion below is trivially true against zero + // connections — and a socket that failed to open leaves an empty list without saying so. + assertThat(slow) + .as("no half-open connections were established, so nothing was abused") + .hasSizeGreaterThanOrEqualTo(32); + assertThat(slow).allMatch(Socket::isConnected); + + assertThat(fixture().stillServing(loadPath())) + .as("64 half-open connections stopped the server from answering a normal request") + .isTrue(); + } finally { + slow.forEach(WebLoadAndShutdownContract::closeQuietly); + } + } + + @Test + @DisplayName("the server recovers after the slow clients go away") + void serverRecoversAfterSlowClientsDisconnect() { + // The half of the property that matters operationally. A server that survives the abuse but + // never reclaims the connections degrades permanently after one incident. + List slow = fixture().openSlowClients(64); + assertThat(slow).hasSizeGreaterThanOrEqualTo(32); + slow.forEach(WebLoadAndShutdownContract::closeQuietly); + + WebLoadResult result = fixture().run(loadPath(), 200, 16); + + assertThat(result.failed()).isZero(); + assertThat(result.completed() + result.rejected()).isEqualTo(200); + } + + private static void closeQuietly(Socket socket) { + try { + socket.close(); + } catch (IOException ignored) { + // Already gone; nothing to reclaim. + } + } +} diff --git a/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/performance/WebLoadFixture.java b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/performance/WebLoadFixture.java new file mode 100644 index 00000000..af490591 --- /dev/null +++ b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/performance/WebLoadFixture.java @@ -0,0 +1,230 @@ +package dev.caskeleton.adapter.inbound.web.testkit.performance; + +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.Socket; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Generates load against a running container and observes what it costs. + * + *

Virtual threads for the client side, so the harness can hold hundreds of connections without + * the test JVM's own thread pool becoming the bottleneck being measured. A load generator that + * saturates before the server does reports the generator's limits as the server's. + * + *

Resource sampling runs on its own thread rather than being read at the end. Peak heap and peak + * thread count are the interesting numbers, and both have usually fallen back by the time a run + * finishes. + */ +public final class WebLoadFixture implements AutoCloseable { + + private final String baseUrl; + private final String host; + private final int port; + + /** + * A fixture against a running container. + * + * @param port the container's port + */ + public WebLoadFixture(int port) { + this.host = "localhost"; + this.port = port; + this.baseUrl = "http://localhost:" + port; + } + + /** + * Runs a fixed number of requests at a fixed concurrency. + * + * @param path the path to request + * @param requests how many requests in total + * @param concurrency how many at once + */ + public WebLoadResult run(String path, int requests, int concurrency) { + List latencies = new CopyOnWriteArrayList<>(); + AtomicInteger rejected = new AtomicInteger(); + AtomicInteger failed = new AtomicInteger(); + ResourceSampler sampler = new ResourceSampler(); + Thread samplerThread = Thread.ofVirtual().start(sampler); + + long startedAt = System.nanoTime(); + // No start latch. An earlier version held every task on one, drained in batches, and released + // the latch after the loop — so the first drain waited on tasks that could not run until the + // loop it was inside had finished. Virtual threads need no starting gun; the semaphore below + // is what bounds concurrency. + Semaphore inFlight = new Semaphore(concurrency); + try (ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor()) { + List> futures = new ArrayList<>(); + for (int index = 0; index < requests; index++) { + futures.add( + pool.submit( + (Callable) + () -> { + inFlight.acquire(); + long began = System.nanoTime(); + try { + int status = get(path); + if (status == 503 || status == 429) { + rejected.incrementAndGet(); + } else if (status >= 500) { + failed.incrementAndGet(); + } else { + latencies.add(Duration.ofNanos(System.nanoTime() - began)); + } + } catch (IOException refused) { + failed.incrementAndGet(); + } finally { + inFlight.release(); + } + return null; + })); + } + drain(futures); + } + Duration wallClock = Duration.ofNanos(System.nanoTime() - startedAt); + sampler.stop(); + try { + samplerThread.join(Duration.ofSeconds(5)); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + } + + return WebLoadResult.of( + List.copyOf(latencies), + rejected.get(), + failed.get(), + sampler.maxHeapBytes(), + sampler.maxThreads(), + wallClock); + } + + /** + * Opens connections that send a partial request and then stop. + * + *

The slow-client shape: bytes trickle, headers never complete, and every connection occupies + * whatever the server allocates per connection until a timeout fires. It is how a handful of + * clients exhaust a thread-per-request pool, and the server's defence is a header timeout rather + * than anything the application can do. + * + * @param connections how many to open + */ + public List openSlowClients(int connections) { + List sockets = new ArrayList<>(); + for (int index = 0; index < connections; index++) { + try { + Socket socket = new Socket(host, port); + socket.setSoTimeout(1_000); + socket + .getOutputStream() + .write( + "GET /api/v1/fixtures/f1 HTTP/1.1\r\nHost: localhost\r\n" + .getBytes(StandardCharsets.UTF_8)); + socket.getOutputStream().flush(); + sockets.add(socket); + } catch (IOException refused) { + // The server refusing further connections is a bounded response, not a failure. The + // assertion is about what it does next, not about how many it accepted. + break; + } + } + return sockets; + } + + /** Whether the container still answers a normal request. */ + public boolean stillServing(String path) { + try { + return get(path) < 500; + } catch (IOException unreachable) { + return false; + } + } + + private int get(String path) throws IOException { + HttpURLConnection connection = + (HttpURLConnection) URI.create(baseUrl + path).toURL().openConnection(); + connection.setConnectTimeout(5_000); + connection.setReadTimeout(20_000); + try { + int status = connection.getResponseCode(); + try (InputStream stream = + status >= 400 ? connection.getErrorStream() : connection.getInputStream()) { + if (stream != null) { + stream.readAllBytes(); + } + } + return status; + } finally { + connection.disconnect(); + } + } + + private static void drain(List> futures) { + for (Future future : futures) { + try { + future.get(60, TimeUnit.SECONDS); + } catch (Exception ignored) { + // Counted by the task itself; a failure to join is not a separate outcome. + } + } + futures.clear(); + } + + @Override + public void close() { + // Nothing owned beyond the per-run executor, which is closed with its try-with-resources. + } + + /** Samples heap and thread count while a run is in flight. */ + private static final class ResourceSampler implements Runnable { + + private volatile boolean running = true; + private volatile long maxHeapBytes; + private volatile int maxThreads; + + @Override + public void run() { + Runtime runtime = Runtime.getRuntime(); + while (running) { + long used = runtime.totalMemory() - runtime.freeMemory(); + if (used > maxHeapBytes) { + maxHeapBytes = used; + } + int threads = Thread.activeCount(); + if (threads > maxThreads) { + maxThreads = threads; + } + try { + Thread.sleep(Duration.ofMillis(20)); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + return; + } + } + } + + private void stop() { + running = false; + } + + private long maxHeapBytes() { + return maxHeapBytes; + } + + private int maxThreads() { + return maxThreads; + } + } +} diff --git a/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/performance/WebLoadResult.java b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/performance/WebLoadResult.java new file mode 100644 index 00000000..1f7657b5 --- /dev/null +++ b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/performance/WebLoadResult.java @@ -0,0 +1,95 @@ +package dev.caskeleton.adapter.inbound.web.testkit.performance; + +import java.time.Duration; +import java.util.List; +import java.util.Objects; + +/** + * What one load run measured. + * + *

Percentiles rather than an average, because an average hides exactly the behaviour a load test + * exists to find. A service that answers a thousand requests in 5 ms and ten in 30 seconds has an + * excellent mean and is broken for the callers who matter — and those ten are the ones the queue, + * the pool and the shedding decisions are all about. + * + *

Resource observations are recorded alongside, because a latency profile achieved by growing + * the heap without bound is not a passing result; it is a failure that has not happened yet. + * + * @param completed how many requests finished + * @param rejected how many were shed + * @param failed how many errored + * @param p50 the median + * @param p95 the 95th percentile + * @param p99 the 99th percentile + * @param max the slowest + * @param maxHeapBytes the highest heap occupancy observed + * @param maxThreads the highest live thread count observed + * @param wallClock how long the run took + */ +public record WebLoadResult( + int completed, + int rejected, + int failed, + Duration p50, + Duration p95, + Duration p99, + Duration max, + long maxHeapBytes, + int maxThreads, + Duration wallClock) { + + public WebLoadResult { + Objects.requireNonNull(p50, "p50"); + Objects.requireNonNull(p95, "p95"); + Objects.requireNonNull(p99, "p99"); + Objects.requireNonNull(max, "max"); + Objects.requireNonNull(wallClock, "wallClock"); + } + + /** Throughput in requests per second. */ + public double throughputPerSecond() { + double seconds = wallClock.toNanos() / 1_000_000_000.0; + return seconds <= 0 ? 0 : completed / seconds; + } + + /** + * Builds a result from observed latencies. + * + * @param latencies every completed request's latency + * @param rejected how many were shed + * @param failed how many errored + * @param maxHeapBytes the highest heap occupancy observed + * @param maxThreads the highest live thread count observed + * @param wallClock how long the run took + */ + public static WebLoadResult of( + List latencies, + int rejected, + int failed, + long maxHeapBytes, + int maxThreads, + Duration wallClock) { + List sorted = latencies.stream().sorted().toList(); + return new WebLoadResult( + sorted.size(), + rejected, + failed, + percentile(sorted, 50), + percentile(sorted, 95), + percentile(sorted, 99), + sorted.isEmpty() ? Duration.ZERO : sorted.get(sorted.size() - 1), + maxHeapBytes, + maxThreads, + wallClock); + } + + private static Duration percentile(List sorted, int percentile) { + if (sorted.isEmpty()) { + return Duration.ZERO; + } + // Nearest-rank. Interpolating between samples invents a latency nothing observed, which for a + // tail percentile is precisely the number people act on. + int rank = (int) Math.ceil(percentile / 100.0 * sorted.size()); + return sorted.get(Math.min(sorted.size(), Math.max(1, rank)) - 1); + } +} diff --git a/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/proxy/ProxyFixtureController.java b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/proxy/ProxyFixtureController.java new file mode 100644 index 00000000..2a302238 --- /dev/null +++ b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/proxy/ProxyFixtureController.java @@ -0,0 +1,101 @@ +package dev.caskeleton.adapter.inbound.web.testkit.proxy; + +import dev.caskeleton.adapter.inbound.web.http.ExternalOrigin; +import dev.caskeleton.adapter.inbound.web.http.ExternalPrefix; +import dev.caskeleton.adapter.inbound.web.http.ExternalUriBuilder; +import dev.caskeleton.adapter.inbound.web.http.ExternalUriPolicy; +import dev.caskeleton.adapter.inbound.web.proxy.ForwardedHeaderSanitizer; +import dev.caskeleton.adapter.inbound.web.proxy.NormalizedForwardedHeaders; +import dev.caskeleton.adapter.inbound.web.proxy.TrustedProxyPolicy; +import jakarta.servlet.http.HttpServletRequest; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import org.springframework.boot.test.context.TestComponent; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +/** + * Reports what the platform concluded about the request's origin. + * + *

Reporting rather than asserting, so the lane can compare what a client sent with what the + * application ended up believing. The interesting cases are the ones where those differ: a client + * that sends {@code X-Forwarded-Host: evil.example} and an application that correctly reports + * {@code hyeonworks.com} is the proxy having done its job. + * + *

Every proxy in a container network is a private address, so the trust policy admits the RFC + * 1918 ranges. Narrower than the default of trusting whatever spoke last, wider than a single + * address nobody can predict. + */ +// @TestComponent for the same reason as the fixture applications: a plain @RestController in this +// package is scanned into the real application, publishing fixture routes in a deployment. +@RestController +@TestComponent +public class ProxyFixtureController { + + /** Reports the resolved external URI. */ + public static final String EXTERNAL_URI_PATH = "/v1/external-uri"; + + /** Reports the resolved client address. */ + public static final String CLIENT_ADDRESS_PATH = "/v1/client-address"; + + /** Echoes the application path the request arrived on. */ + public static final String ARRIVED_PATH = "/v1/arrived-path"; + + /** Accepts a body, so the proxy's own size limit is observable. */ + public static final String ECHO_PATH = "/v1/echo"; + + private final ForwardedHeaderSanitizer sanitizer = + new ForwardedHeaderSanitizer( + TrustedProxyPolicy.of("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "127.0.0.0/8")); + + /** + * The absolute URI the platform would publish for a resource. + * + *

This is the value that ends up in a {@code Location} header, so it is the one an open + * redirect would come out of: an application that believed a client's forwarded host would mint + * links pointing at the attacker's domain and hand them to the browser. + */ + @GetMapping(path = EXTERNAL_URI_PATH, produces = MediaType.TEXT_PLAIN_VALUE) + public String externalUri(HttpServletRequest request) { + NormalizedForwardedHeaders forwarded = + sanitizer.normalize(request.getRemoteAddr(), headersOf(request)); + ExternalUriPolicy policy = ExternalUriPolicy.fromTrustedProxy(ExternalPrefix.none()); + ExternalOrigin origin = + policy.resolveOrigin( + forwarded, + new ExternalOrigin( + request.getScheme(), request.getServerName(), request.getServerPort())); + return new ExternalUriBuilder(origin, policy.resolvePrefix(forwarded)).absolute("/v1/result"); + } + + /** The client address the platform resolved. */ + @GetMapping(path = CLIENT_ADDRESS_PATH, produces = MediaType.TEXT_PLAIN_VALUE) + public String clientAddress(HttpServletRequest request) { + NormalizedForwardedHeaders forwarded = + sanitizer.normalize(request.getRemoteAddr(), headersOf(request)); + return forwarded.clientAddress().orElseGet(request::getRemoteAddr); + } + + /** The application path the request actually arrived on. */ + @GetMapping(path = ARRIVED_PATH, produces = MediaType.TEXT_PLAIN_VALUE) + public String arrivedPath(HttpServletRequest request) { + return request.getRequestURI(); + } + + /** Reports how many bytes of body arrived. */ + @PostMapping(path = ECHO_PATH, produces = MediaType.TEXT_PLAIN_VALUE) + public String echo(@RequestBody(required = false) byte[] body) { + return Integer.toString(body == null ? 0 : body.length); + } + + private static Map headersOf(HttpServletRequest request) { + Map headers = new LinkedHashMap<>(); + Collections.list(request.getHeaderNames()) + .forEach(name -> headers.put(name, request.getHeader(name))); + return headers; + } +} diff --git a/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/throttle/CountingRateLimiter.java b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/throttle/CountingRateLimiter.java new file mode 100644 index 00000000..0e28707d --- /dev/null +++ b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/throttle/CountingRateLimiter.java @@ -0,0 +1,51 @@ +package dev.caskeleton.adapter.inbound.web.testkit.throttle; + +import dev.caskeleton.adapter.inbound.web.core.WebRequestContext; +import dev.caskeleton.adapter.inbound.web.ratelimit.RateLimitDecision; +import dev.caskeleton.adapter.inbound.web.ratelimit.RateLimitProfileName; +import dev.caskeleton.adapter.inbound.web.ratelimit.WebRateLimiter; +import java.time.Duration; +import java.time.Instant; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +/** + * A quota that counts and can be told to fail. + * + *

Fixed window, not a token bucket. The lanes assert what a client is told when the quota is + * spent, and a window that refills mid-test would make the refusal flaky for reasons that have + * nothing to do with the transport. + * + *

The failure switch exists because a limiter's store being unreachable is the case with two + * defensible answers and no default, and both must be observable end to end. + */ +public final class CountingRateLimiter implements WebRateLimiter { + + private final AtomicInteger used = new AtomicInteger(); + private final AtomicReference failure = new AtomicReference<>(); + + @Override + public RateLimitDecision evaluate(WebRequestContext context, RateLimitProfileName profile) { + RuntimeException injected = failure.get(); + if (injected != null) { + throw injected; + } + Instant resetAt = Instant.now().plusSeconds(60); + int spent = used.incrementAndGet(); + return spent > ThrottleFixtureProtocol.QUOTA + ? RateLimitDecision.refused(ThrottleFixtureProtocol.QUOTA, resetAt, Duration.ofSeconds(1)) + : RateLimitDecision.allowed( + ThrottleFixtureProtocol.QUOTA, ThrottleFixtureProtocol.QUOTA - spent, resetAt); + } + + /** Refills the window. */ + public void reset() { + used.set(0); + failure.set(null); + } + + /** Makes every evaluation throw, as an unreachable store would. */ + public void breakStore() { + failure.set(new IllegalStateException("the rate limit store is unreachable")); + } +} diff --git a/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/throttle/HttpThrottleFixture.java b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/throttle/HttpThrottleFixture.java new file mode 100644 index 00000000..1dab476d --- /dev/null +++ b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/throttle/HttpThrottleFixture.java @@ -0,0 +1,172 @@ +package dev.caskeleton.adapter.inbound.web.testkit.throttle; + +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +/** + * Drives the throttle routes over a real socket. + * + *

The saturation helper starts a request that holds its slot and waits until it is genuinely + * running before the next call. Firing two requests and hoping the first arrives first is the shape + * that makes a 503 assertion flaky, and a flaky throttle test is one people learn to rerun. + */ +public final class HttpThrottleFixture implements AutoCloseable { + + /** + * One response, as the client saw it. + * + * @param status the HTTP status + * @param body the response body + * @param retryAfter the {@code Retry-After} header, or null + */ + public record Response(int status, String body, String retryAfter) { + + /** The problem code the body names, or null. */ + public String problemCode() { + int at = body.indexOf("\"code\":\""); + if (at < 0) { + return null; + } + int from = at + 8; + return body.substring(from, body.indexOf('"', from)); + } + } + + private final String baseUrl; + private final ExecutorService holders = Executors.newCachedThreadPool(); + + /** + * A fixture against a running container. + * + * @param port the container's port + */ + public HttpThrottleFixture(int port) { + this.baseUrl = "http://localhost:" + port; + } + + /** Refills the quota and releases any held slot. */ + public void reset() { + send("POST", ThrottleFixtureProtocol.RESET_PATH, 5_000); + } + + /** Refills the quota, then makes the limiter's store unreachable. */ + public void breakLimiterStore() { + send("POST", ThrottleFixtureProtocol.RESET_PATH + "?breakStore=true", 5_000); + } + + /** One call against the quota route. */ + public Response callQuotaRoute() { + return send("GET", ThrottleFixtureProtocol.QUOTA_PATH, 5_000); + } + + /** Spends the whole quota, then calls once more. */ + public Response exhaustAndCall() { + for (int index = 0; index < ThrottleFixtureProtocol.QUOTA; index++) { + callQuotaRoute(); + } + return callQuotaRoute(); + } + + /** One call against the write route. */ + public Response callWriteRoute() { + return send("POST", ThrottleFixtureProtocol.WRITE_PATH, 10_000); + } + + /** + * Occupies the single write slot, then calls the write route again. + * + *

The holder is confirmed to be inside the handler before the second call goes out, so the + * refusal is caused by the slot being taken rather than by whichever request happened to win. + */ + public Response saturateAndCallWrite() { + send("POST", ThrottleFixtureProtocol.RESET_PATH + "?arm=true", 5_000); + CompletableFuture holder = + CompletableFuture.supplyAsync( + () -> send("POST", ThrottleFixtureProtocol.WRITE_PATH + "?hold=true", 40_000), holders); + try { + awaitSlotTaken(holder); + // Refill the quota without letting go of the slot. Confirming the slot was taken cost + // several quota-charged probes, and the filter charges quota before it asks for capacity — + // so without this the next call is refused 429 and the 503 case never runs. + send("POST", ThrottleFixtureProtocol.RESET_PATH + "?releaseHold=false", 5_000); + return callWriteRoute(); + } finally { + reset(); + try { + holder.get(30, TimeUnit.SECONDS); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + } catch (ExecutionException | TimeoutException ignored) { + // The holder's own outcome is not what this scenario asserts. + } + } + } + + private void awaitSlotTaken(CompletableFuture holder) { + // Polled rather than slept: a fixed sleep is either too short on a loaded machine or wasted + // time on an idle one, and the first is a flaky failure nobody can reproduce. + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10); + while (System.nanoTime() < deadline) { + if (holder.isDone()) { + throw new IllegalStateException("the holding request returned before the slot was taken"); + } + // Refilled before every probe, not only after the loop. The filter charges quota before it + // asks for capacity, so each probe spends some — and on a machine loaded enough that the + // holder takes a while to occupy the slot, the quota runs out first and every remaining + // probe answers 429. The loop then never sees its 503 and fails on the deadline, which + // reads as a capacity bug and is a fixture bug. + send("POST", ThrottleFixtureProtocol.RESET_PATH + "?releaseHold=false", 5_000); + Response probe = send("POST", ThrottleFixtureProtocol.WRITE_PATH, 5_000); + if (probe.status() == 503) { + return; + } + if (probe.status() == 429) { + throw new IllegalStateException( + "a probe was refused for quota immediately after a refill, so the refill is not " + + "refilling and this scenario cannot reach the capacity case"); + } + } + throw new IllegalStateException("the write slot was never taken"); + } + + @Override + public void close() { + holders.shutdownNow(); + } + + private Response send(String method, String path, int readTimeoutMillis) { + try { + HttpURLConnection connection = + (HttpURLConnection) URI.create(baseUrl + path).toURL().openConnection(); + connection.setRequestMethod(method); + connection.setConnectTimeout(5_000); + connection.setReadTimeout(readTimeoutMillis); + if ("POST".equals(method)) { + connection.setDoOutput(true); + connection.getOutputStream().close(); + } + try { + int status = connection.getResponseCode(); + String body; + try (InputStream stream = + status >= 400 ? connection.getErrorStream() : connection.getInputStream()) { + body = stream == null ? "" : new String(stream.readAllBytes(), StandardCharsets.UTF_8); + } + return new Response(status, body, connection.getHeaderField("Retry-After")); + } finally { + connection.disconnect(); + } + } catch (IOException e) { + throw new IllegalStateException(method + " " + path + " failed", e); + } + } +} diff --git a/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/throttle/ThrottleControlController.java b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/throttle/ThrottleControlController.java new file mode 100644 index 00000000..f7ba0192 --- /dev/null +++ b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/throttle/ThrottleControlController.java @@ -0,0 +1,61 @@ +package dev.caskeleton.adapter.inbound.web.testkit.throttle; + +import org.springframework.boot.test.context.TestComponent; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * The out-of-band control the lanes drive the fixture with. + * + *

Separate from the throttled routes because these must never be throttled themselves: a reset + * that the exhausted quota refuses would leave the fixture stuck, and every case after the first + * would fail for the wrong reason. + */ +// @TestComponent for the same reason as the fixture applications: a plain @RestController in this +// package is scanned into the real application, publishing fixture routes in a deployment. +@RestController +@TestComponent +public class ThrottleControlController { + + private final CountingRateLimiter limiter; + private final ThrottleFixtureController routes; + + /** + * A control over the fixture's limiter and routes. + * + * @param limiter the quota being driven + * @param routes the routes holding admission slots + */ + public ThrottleControlController(CountingRateLimiter limiter, ThrottleFixtureController routes) { + this.limiter = limiter; + this.routes = routes; + } + + /** + * Refills the quota, clears any injected failure, and optionally releases a held slot. + * + *

The two are separable because a lane driving the capacity case needs to refill the quota + * *without* letting go of the slot: the filter charges quota before it asks for capacity, so the + * probing that confirms the slot is taken spends quota too, and a 429 would arrive where the 503 + * was being tested. + */ + @PostMapping(ThrottleFixtureProtocol.RESET_PATH) + public ResponseEntity reset( + @RequestParam(name = "breakStore", defaultValue = "false") boolean breakStore, + @RequestParam(name = "arm", defaultValue = "false") boolean arm, + @RequestParam(name = "releaseHold", defaultValue = "true") boolean releaseHold) { + if (releaseHold) { + routes.releaseHold(); + } + limiter.reset(); + if (breakStore) { + limiter.breakStore(); + } + if (arm) { + routes.arm(); + } + return ResponseEntity.noContent().build(); + } +} diff --git a/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/throttle/ThrottleFixtureController.java b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/throttle/ThrottleFixtureController.java new file mode 100644 index 00000000..beed0cce --- /dev/null +++ b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/throttle/ThrottleFixtureController.java @@ -0,0 +1,62 @@ +package dev.caskeleton.adapter.inbound.web.testkit.throttle; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import org.springframework.boot.test.context.TestComponent; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * A quota route and a write route that can hold its admission slot. + * + *

Holding is how a lane saturates capacity deterministically. Generating enough concurrent load + * to fill a real pool would make the refusal a matter of timing, and a test that observes 503 only + * sometimes is one nobody trusts when it fails. + */ +// @TestComponent for the same reason as the fixture applications: a plain @RestController in this +// package is scanned into the real application, publishing fixture routes in a deployment. +@RestController +@TestComponent +public class ThrottleFixtureController { + + private final AtomicReference release = + new AtomicReference<>(new CountDownLatch(0)); + + /** A read governed by the quota. */ + @GetMapping(path = ThrottleFixtureProtocol.QUOTA_PATH, produces = MediaType.TEXT_PLAIN_VALUE) + public ResponseEntity quotaRoute() { + return ResponseEntity.ok("ok"); + } + + /** A write that optionally holds its admission slot until released. */ + @PostMapping(path = ThrottleFixtureProtocol.WRITE_PATH, produces = MediaType.TEXT_PLAIN_VALUE) + public ResponseEntity writeRoute( + @RequestParam(name = ThrottleFixtureProtocol.HOLD_PARAM, defaultValue = "false") + boolean hold) { + if (hold) { + try { + // Bounded, so a lane that fails before releasing cannot hang the container's thread pool + // for the rest of the run. + release.get().await(30, TimeUnit.SECONDS); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + } + } + return ResponseEntity.ok("ok"); + } + + /** Arms a new hold. */ + public void arm() { + release.set(new CountDownLatch(1)); + } + + /** Releases whatever is holding a slot. */ + public void releaseHold() { + release.get().countDown(); + } +} diff --git a/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/throttle/ThrottleFixtureProtocol.java b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/throttle/ThrottleFixtureProtocol.java new file mode 100644 index 00000000..38d22a19 --- /dev/null +++ b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/throttle/ThrottleFixtureProtocol.java @@ -0,0 +1,28 @@ +package dev.caskeleton.adapter.inbound.web.testkit.throttle; + +/** + * The routes and quotas the throttle lanes use. + * + *

The quota is tiny and the write profile admits one at a time, because both refusals are about + * a boundary being crossed and neither gets truer at production scale. A lane sized realistically + * would spend minutes generating load to observe the same two responses. + */ +public final class ThrottleFixtureProtocol { + + private ThrottleFixtureProtocol() {} + + /** How many requests the fixture quota admits before refusing. */ + public static final int QUOTA = 3; + + /** A route governed by the quota. */ + public static final String QUOTA_PATH = "/api/v1/fixtures/throttle/quota"; + + /** A write route whose admission profile admits one request at a time. */ + public static final String WRITE_PATH = "/api/v1/fixtures/throttle/write"; + + /** Resets the quota and releases any held slot. */ + public static final String RESET_PATH = "/api/v1/fixtures/throttle/reset"; + + /** Tells the write route to hold its slot until released. */ + public static final String HOLD_PARAM = "hold"; +} diff --git a/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/throttle/ThrottleFixtureSupport.java b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/throttle/ThrottleFixtureSupport.java new file mode 100644 index 00000000..d46a1c83 --- /dev/null +++ b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/throttle/ThrottleFixtureSupport.java @@ -0,0 +1,75 @@ +package dev.caskeleton.adapter.inbound.web.testkit.throttle; + +import dev.caskeleton.adapter.inbound.web.admission.AdmissionProfile; +import dev.caskeleton.adapter.inbound.web.admission.SemaphoreAdmissionController; +import dev.caskeleton.adapter.inbound.web.core.ActorContext; +import dev.caskeleton.adapter.inbound.web.core.ApiMajorVersion; +import dev.caskeleton.adapter.inbound.web.core.ExternalRequestContext; +import dev.caskeleton.adapter.inbound.web.core.TenantContext; +import dev.caskeleton.adapter.inbound.web.core.WebOperationName; +import dev.caskeleton.adapter.inbound.web.core.WebRequestContext; +import dev.caskeleton.adapter.inbound.web.core.WebRequestId; +import dev.caskeleton.adapter.inbound.web.core.WebTraceId; +import dev.caskeleton.adapter.inbound.web.operation.AdmissionProfileName; +import dev.caskeleton.adapter.inbound.web.operation.WebOperationProfile; +import java.time.Duration; +import java.time.Instant; +import java.util.Locale; +import java.util.Set; + +/** + * The profiles and the context both throttle fixtures use. + * + *

The write profile admits exactly one request. The production {@code global-write} profile + * admits thirty-two, which is right for a service and useless for a lane: filling it would take + * thirty-three concurrent connections and the result would still be a race. + */ +public final class ThrottleFixtureSupport { + + private ThrottleFixtureSupport() {} + + /** The write profile the lanes saturate. */ + public static AdmissionProfile writeProfile() { + return new AdmissionProfile( + new AdmissionProfileName("global-write"), 1, 1, Duration.ofMillis(50)); + } + + /** An admission controller over the fixture profiles. */ + public static SemaphoreAdmissionController admissionController() { + return new SemaphoreAdmissionController(AdmissionProfile.standard(), writeProfile()); + } + + /** The operation profile a path maps to. */ + public static WebOperationProfile profileFor(String path) { + if (path.startsWith(ThrottleFixtureProtocol.WRITE_PATH)) { + return new WebOperationProfile( + new WebOperationName("throttle.write"), + dev.caskeleton.adapter.inbound.web.operation.HttpMethodSemantic.POST, + dev.caskeleton.adapter.inbound.web.operation.MutationKind.CREATE, + dev.caskeleton.adapter.inbound.web.budget.WebBudgetProfileName.standard(), + dev.caskeleton.adapter.inbound.web.operation.AuthorizationProfileName.standard(), + dev.caskeleton.adapter.inbound.web.operation.IdempotencyPolicy.OPTIONAL, + dev.caskeleton.adapter.inbound.web.operation.PreconditionPolicy.NONE, + dev.caskeleton.adapter.inbound.web.operation.CachePolicyName.standard(), + writeProfile().name(), + dev.caskeleton.adapter.inbound.web.operation.ResponseProfileName.standard()); + } + return WebOperationProfile.readOnly("throttle.read"); + } + + /** The context every fixture request runs under. */ + public static WebRequestContext context() { + Instant now = Instant.now(); + return new WebRequestContext( + new WebRequestId("req-throttle"), + new WebTraceId("0af7651916cd43dd8448eb211c80319c"), + new WebOperationName("throttle.read"), + new ApiMajorVersion(1), + ActorContext.authenticated("fixture", Set.of()), + TenantContext.none(), + Locale.ENGLISH, + now, + now.plusSeconds(60), + new ExternalRequestContext("http", "localhost", 80, "")); + } +} diff --git a/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/throttle/WebThrottleHttpContract.java b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/throttle/WebThrottleHttpContract.java new file mode 100644 index 00000000..4e74a199 --- /dev/null +++ b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/throttle/WebThrottleHttpContract.java @@ -0,0 +1,122 @@ +package dev.caskeleton.adapter.inbound.web.testkit.throttle; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * The two refusals that mean "not now", and the requirement that they stay different. + * + *

Most of these cases exist to defend one distinction: a spent quota is the caller's doing and + * is 429; a full service is not and is 503. Collapsing them is easy — both are "too much traffic" + * from the server's side — and it tells every client the wrong thing half the time. A client told + * 429 during an incident slows itself down permanently; one told 503 for its own overuse keeps + * hammering and waits for a recovery that will not come. + */ +public abstract class WebThrottleHttpContract { + + /** The lane's fixture. */ + protected abstract HttpThrottleFixture fixture(); + + @BeforeEach + void resetFixture() { + fixture().reset(); + } + + @Test + @DisplayName("a request inside the quota is served") + void requestWithinQuotaIsServed() { + assertThat(fixture().callQuotaRoute().status()).isEqualTo(200); + } + + @Test + @DisplayName("exhausting the quota answers 429 with a Retry-After") + void quotaExhaustionReturns429AndRetryAfter() { + HttpThrottleFixture.Response response = fixture().exhaustAndCall(); + + assertThat(response.status()).isEqualTo(429); + assertThat(response.problemCode()).isEqualTo("RATE_LIMITED"); + assertThat(response.retryAfter()).isNotNull(); + } + + @Test + @DisplayName("Retry-After is never zero") + void retryAfterIsNeverZero() { + // RFC 9110 counts in seconds, so a sub-second wait rendered honestly is "0" — which tells the + // client to retry immediately, the opposite of what a refusal means. + assertThat(Integer.parseInt(fixture().exhaustAndCall().retryAfter())).isGreaterThanOrEqualTo(1); + } + + @Test + @DisplayName("a saturated write profile answers 503, not 429") + void saturatedWriteAdmissionReturns503() { + HttpThrottleFixture.Response response = fixture().saturateAndCallWrite(); + + assertThat(response.status()).isEqualTo(503); + assertThat(response.problemCode()).isEqualTo("ADMISSION_REJECTED"); + } + + @Test + @DisplayName("a capacity refusal does not blame the caller") + void capacityRefusalIsNotAQuotaRefusal() { + // The distinction this whole pair exists for. A 503 whose body reads like a rate limit sends + // a well-behaved client into a backoff it never needed to enter. + HttpThrottleFixture.Response response = fixture().saturateAndCallWrite(); + + assertThat(response.problemCode()).isNotEqualTo("RATE_LIMITED"); + assertThat(response.body()).doesNotContain("quota"); + } + + @Test + @DisplayName("a capacity refusal still says when to come back") + void capacityRefusalCarriesRetryAfter() { + assertThat(fixture().saturateAndCallWrite().retryAfter()).isNotNull(); + } + + @Test + @DisplayName("a request refused for capacity has still spent the caller's quota") + void capacityRefusalStillChargesQuota() { + // Stated rather than hidden, because it follows from charging quota before asking for + // capacity — and that order is deliberate: asking for capacity first lets a caller who is + // about to be rate-limited occupy a slot on its way to being told so. + // + // The cost is real. Under sustained overload every caller's quota drains on requests the + // service never ran, so they are eventually told 429 for a problem that was 503. An operator + // seeing 429 climb during an incident should read it as a symptom of the shedding, not as + // callers suddenly misbehaving. + fixture().saturateAndCallWrite(); + fixture().breakLimiterStore(); + + // With the limiter's store unreachable and the profile fail-open, the quota is out of the + // picture — so this asserts the capacity path alone still works after the earlier drain. + assertThat(fixture().callWriteRoute().status()).isEqualTo(200); + } + + @Test + @DisplayName("capacity is returned when the holding request finishes") + void capacityIsReleasedAfterTheRequestCompletes() { + // The failure this catches is a permit released on one exit path and not another: throughput + // decays towards zero over hours and nothing in the logs says why. + fixture().saturateAndCallWrite(); + + assertThat(fixture().callWriteRoute().status()).isEqualTo(200); + } + + @Test + @DisplayName("an unreachable limiter store serves the request when the profile says fail-open") + void failOpenServesWhenTheLimiterIsDown() { + // The profile's decision, made at review time. What must not happen is the limiter deciding + // it by whatever it does when the store throws. + fixture().breakLimiterStore(); + + assertThat(fixture().callQuotaRoute().status()).isEqualTo(200); + } + + @Test + @DisplayName("the quota refusal is a problem document, not a bare status") + void quotaRefusalIsAProblemDocument() { + assertThat(fixture().exhaustAndCall().body()).contains("\"type\"", "\"title\"", "\"status\""); + } +} diff --git a/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/webflux/ReactiveContractFixtureController.java b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/webflux/ReactiveContractFixtureController.java new file mode 100644 index 00000000..5a5d402c --- /dev/null +++ b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/adapter/inbound/web/testkit/webflux/ReactiveContractFixtureController.java @@ -0,0 +1,73 @@ +package dev.caskeleton.adapter.inbound.web.testkit.webflux; + +import dev.caskeleton.adapter.inbound.web.webflux.context.WebFluxRequestContextAccessor; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import java.net.URI; +import org.springframework.boot.test.context.TestComponent; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import reactor.core.publisher.Mono; + +/** + * The reactive routes the Reactor Netty gate exercises. + * + *

Deliberately the same shape as the servlet fixture, so the two gates certify the same contract + * rather than two similar ones. The extra route is {@code /thread}: it reports the thread the + * handler ran on and the request id it read from the Reactor Context, which is the only way to show + * from outside that the context survived the hop onto an event loop. + */ +// @TestComponent for the same reason as the fixture applications: a plain @RestController in this +// package is scanned into the real application, publishing fixture routes in a deployment. +@RestController +@TestComponent +@RequestMapping("/api/v1/fixtures") +public class ReactiveContractFixtureController { + + /** A request body with one constraint, so a rejection is reachable. */ + public record FixtureRequest(@NotBlank String name) {} + + /** The resource, returned directly rather than inside an envelope. */ + public record FixtureResponse(String id, String name) {} + + /** What the handler observed about its own execution. */ + public record ExecutionReport(String threadName, String requestId) {} + + /** Creates; the new resource must be locatable. */ + @PostMapping( + consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE) + public Mono> create(@Valid @RequestBody FixtureRequest request) { + return Mono.just( + ResponseEntity.created(URI.create("/api/v1/fixtures/f1")) + .body(new FixtureResponse("f1", request.name()))); + } + + /** Reads; carries a validator so HEAD parity is observable. */ + @GetMapping(path = "/{id}", produces = MediaType.APPLICATION_JSON_VALUE) + public Mono> read(@PathVariable String id) { + return Mono.just(ResponseEntity.ok().eTag("\"v1\"").body(new FixtureResponse(id, "fixture"))); + } + + /** Deletes; nothing may be written. */ + @DeleteMapping("/{id}") + public Mono> delete(@PathVariable String id) { + return Mono.just(ResponseEntity.noContent().build()); + } + + /** Reports the execution thread and the request id read from the Reactor Context. */ + @GetMapping(path = "/thread", produces = MediaType.APPLICATION_JSON_VALUE) + public Mono thread() { + return WebFluxRequestContextAccessor.require() + .map( + context -> + new ExecutionReport(Thread.currentThread().getName(), context.requestId().value())); + } +} diff --git a/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/webtestkit/BudgetFixtureApplication.java b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/webtestkit/BudgetFixtureApplication.java new file mode 100644 index 00000000..cb369399 --- /dev/null +++ b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/webtestkit/BudgetFixtureApplication.java @@ -0,0 +1,72 @@ +package dev.caskeleton.webtestkit; + +import dev.caskeleton.adapter.inbound.web.error.BudgetProblemMapper; +import dev.caskeleton.adapter.inbound.web.error.WebProblemFactory; +import dev.caskeleton.adapter.inbound.web.json.WebObjectMapperFactory; +import dev.caskeleton.adapter.inbound.web.mvc.budget.WebMvcBudgetExceptionHandler; +import dev.caskeleton.adapter.inbound.web.mvc.budget.WebMvcBudgetFilter; +import dev.caskeleton.adapter.inbound.web.testkit.budget.BudgetFixtureController; +import dev.caskeleton.adapter.inbound.web.testkit.budget.BudgetFixtureProtocol; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.core.Ordered; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.web.SecurityFilterChain; + +/** + * A servlet application whose only purpose is to have small budgets. + * + *

Separate from the status-contract application on purpose. Installing a 1 KiB body bound there + * would silently constrain every other fixture in it, and a later contract case failing on size + * would look like a status bug. + */ +// Outside `dev.caskeleton.adapter.inbound.web`, which the composition root component-scans. +// A fixture application under that root is loaded into the real deployment: its permit-all +// SecurityFilterChain shadows the real one and the context fails to start with an unreachable +// filter chain. @TestConfiguration would exclude it from scanning but is not accepted by +// @SpringBootTest(classes = ...), so the package is what keeps it out. +@Configuration(proxyBeanMethods = false) +@EnableAutoConfiguration +@Import({BudgetFixtureController.class, WebMvcBudgetExceptionHandler.class}) +public class BudgetFixtureApplication { + + /** + * The budget filter, ahead of everything that reads the request. + * + *

{@code HIGHEST_PRECEDENCE} is the requirement rather than a preference: a bound applied + * after another filter has read the body has already let the body in. + */ + @Bean + FilterRegistrationBean budgetFilter() { + FilterRegistrationBean registration = + new FilterRegistrationBean<>( + new WebMvcBudgetFilter( + BudgetFixtureProtocol.budget(), + budgetProblemMapper(), + WebObjectMapperFactory.standardJsonMapper())); + registration.setOrder(Ordered.HIGHEST_PRECEDENCE); + return registration; + } + + /** Renders a bound crossed inside the handler. */ + @Bean + BudgetProblemMapper budgetProblemMapper() { + return new BudgetProblemMapper(WebProblemFactory.standard()); + } + + /** + * Every fixture route is open. + * + *

The leaf carries the security starter, so without this the lane answers 401 and proves + * nothing about budgets. It is a fixture in the test tree and reaches no deployment. + */ + @Bean + SecurityFilterChain budgetFixtureSecurity(HttpSecurity http) throws Exception { + return http.csrf(csrf -> csrf.disable()) + .authorizeHttpRequests(requests -> requests.anyRequest().permitAll()) + .build(); + } +} diff --git a/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/webtestkit/ContractFixtureApplication.java b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/webtestkit/ContractFixtureApplication.java new file mode 100644 index 00000000..11d949b8 --- /dev/null +++ b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/webtestkit/ContractFixtureApplication.java @@ -0,0 +1,74 @@ +package dev.caskeleton.webtestkit; + +import dev.caskeleton.adapter.inbound.web.error.ProblemCatalog; +import dev.caskeleton.adapter.inbound.web.error.WebProblemFactory; +import dev.caskeleton.adapter.inbound.web.mvc.error.WebMvcProblemExceptionHandler; +import dev.caskeleton.adapter.inbound.web.testkit.fault.FaultFixtureController; +import dev.caskeleton.adapter.inbound.web.testkit.mvc.ContractFixtureController; +import dev.caskeleton.adapter.inbound.web.testkit.operation.OperationFixtureController; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.web.SecurityFilterChain; + +/** + * The smallest servlet application that can serve the contract fixtures. + * + *

No component scan. Scanning would pull in whatever else this leaf ships — the security chain, + * the file server, the notification callbacks — and the gate would then be testing the sum of them + * rather than the status contract, so a later addition to the leaf could silently change what this + * gate covers. The fixture controller is imported by name for the same reason. + * + *

Auto-configuration is left to Spring Boot's own import registry rather than a hand-written + * list: the list would have to be updated every time Boot moves a class between packages, and a + * stale entry fails the gate for a reason that has nothing to do with the contract. + */ +// Outside `dev.caskeleton.adapter.inbound.web`, which the composition root component-scans. +// A fixture application under that root is loaded into the real deployment: its permit-all +// SecurityFilterChain shadows the real one and the context fails to start with an unreachable +// filter chain. @TestConfiguration would exclude it from scanning but is not accepted by +// @SpringBootTest(classes = ...), so the package is what keeps it out. +@Configuration(proxyBeanMethods = false) +@EnableAutoConfiguration +@Import({ + ContractFixtureController.class, + FaultFixtureController.class, + OperationFixtureController.class, + WebMvcProblemExceptionHandler.class +}) +public class ContractFixtureApplication { + + /** + * The problem catalog the exception handler publishes through. + * + *

Declared here because the gate is about what a client receives, and without it Spring's own + * {@code ProblemDetail} answers instead — RFC 9457-shaped, so it looks right, and carrying no + * {@code code} for a client to branch on. + */ + @Bean + WebProblemFactory contractProblemFactory() { + return new WebProblemFactory( + ProblemCatalog.standard(), + new dev.caskeleton.adapter.inbound.web.error.WebProblemSanitizer()); + } + + /** + * Every fixture route is open. + * + *

The leaf carries {@code spring-boot-starter-security}, so without this the whole gate + * answers 401 and proves nothing about the status contract. Declaring the chain rather than + * excluding the auto-configuration by class name is deliberate: Spring Boot has moved those + * classes between packages, and a stale exclusion fails the gate for a reason that has nothing to + * do with what it is testing. + * + *

This is a fixture in the test tree. It reaches no deployment. + */ + @Bean + SecurityFilterChain contractFixtureSecurity(HttpSecurity http) throws Exception { + return http.csrf(csrf -> csrf.disable()) + .authorizeHttpRequests(requests -> requests.anyRequest().permitAll()) + .build(); + } +} diff --git a/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/webtestkit/PipelineOrderFixtureApplication.java b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/webtestkit/PipelineOrderFixtureApplication.java new file mode 100644 index 00000000..ad8f6a08 --- /dev/null +++ b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/webtestkit/PipelineOrderFixtureApplication.java @@ -0,0 +1,156 @@ +package dev.caskeleton.webtestkit; + +import dev.caskeleton.adapter.inbound.web.testkit.order.PipelineOrderFixtureController; +import dev.caskeleton.adapter.inbound.web.testkit.order.WebPipelineRecorder; +import dev.caskeleton.adapter.inbound.web.testkit.order.WebPipelineStage; +import jakarta.servlet.DispatcherType; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.EnumSet; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.web.filter.OncePerRequestFilter; +import org.springframework.web.servlet.HandlerInterceptor; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +/** + * A servlet application whose only job is to record the order its stages run in. + * + *

Filters carry the stages that must happen before routing; interceptors carry the ones that + * need the handler to be known. That split is the semantic order made structural rather than + * asserted: a stage cannot be moved across it by changing a number. + * + *

The async route exists because MVC dispatches an async request through the filter chain twice + * — once for the initial request and again on the ASYNC redispatch when the result is ready. That + * is the mechanism behind duplicated access logs and double-counted metrics, and it only appears on + * a route that actually returns a {@code Callable}. + */ +// Outside `dev.caskeleton.adapter.inbound.web`, which the composition root component-scans. +// A fixture application under that root is loaded into the real deployment: its permit-all +// SecurityFilterChain shadows the real one and the context fails to start with an unreachable +// filter chain. @TestConfiguration would exclude it from scanning but is not accepted by +// @SpringBootTest(classes = ...), so the package is what keeps it out. +@Configuration(proxyBeanMethods = false) +@EnableAutoConfiguration +@Import(PipelineOrderFixtureController.class) +public class PipelineOrderFixtureApplication implements WebMvcConfigurer { + + private final WebPipelineRecorder recorder = new WebPipelineRecorder(); + + /** The recorder the fixture writes to. */ + @Bean + WebPipelineRecorder pipelineRecorder() { + return recorder; + } + + /** + * The pre-routing stages, in one filter. + * + *

One filter rather than five, because the ordering between them is what is under test and a + * single method makes it a matter of reading top to bottom rather than of comparing registration + * numbers spread across a configuration class. + */ + @Bean + FilterRegistrationBean edgeFilter() { + OncePerRequestFilter filter = + new OncePerRequestFilter() { + @Override + protected void doFilterInternal( + HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws ServletException, IOException { + recorder.record(WebPipelineStage.FORWARDED_NORMALIZATION); + recorder.record(WebPipelineStage.CORS); + recorder.record(WebPipelineStage.TRACE); + recorder.record(WebPipelineStage.AUTHENTICATION); + recorder.record(WebPipelineStage.GLOBAL_ADMISSION); + try { + chain.doFilter(request, response); + } finally { + recordCompletionOnce(request); + } + } + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + // The read-back and reset routes must not record anything, or observing the pipeline + // would change it and the assertion would be about the observation. + return isControlRoute(request.getRequestURI()); + } + + @Override + protected boolean shouldNotFilterAsyncDispatch() { + // false: the filter does run on the ASYNC redispatch, which is what makes the + // duplicate-observation hazard reachable at all. Suppressing the second pass here + // would hide the bug rather than prove it absent. + return false; + } + }; + FilterRegistrationBean registration = + new FilterRegistrationBean<>(filter); + registration.setDispatcherTypes(EnumSet.of(DispatcherType.REQUEST, DispatcherType.ASYNC)); + registration.setOrder(org.springframework.core.Ordered.HIGHEST_PRECEDENCE); + return registration; + } + + private void recordCompletionOnce(HttpServletRequest request) { + // The whole point. On the initial pass of an async request the response is not finished, so + // recording here would count a request that has not completed and then count it again on the + // redispatch. One logical request, one observation. + if (request.isAsyncStarted()) { + return; + } + recorder.record(WebPipelineStage.RESPONSE_OR_PROBLEM); + recorder.record(WebPipelineStage.OBSERVATION); + } + + private static boolean isControlRoute(String path) { + return path.startsWith(PipelineOrderFixtureController.OBSERVED_PATH) + || path.startsWith(PipelineOrderFixtureController.RESET_PATH); + } + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry + .addInterceptor( + new HandlerInterceptor() { + @Override + public boolean preHandle( + HttpServletRequest request, HttpServletResponse response, Object handler) { + // An interceptor runs only after handler mapping, so reaching here is itself the + // evidence that the route was selected — the stage is recorded from the fact, not + // from a hopeful call placed somewhere earlier. + if (request.getDispatcherType() == DispatcherType.ASYNC) { + return true; + } + recorder.record(WebPipelineStage.ROUTE_SELECTED); + recorder.record(WebPipelineStage.ROUTE_POLICY); + return true; + } + }) + .excludePathPatterns( + PipelineOrderFixtureController.OBSERVED_PATH, + PipelineOrderFixtureController.RESET_PATH); + } + + /** + * Every fixture route is open. + * + *

The leaf carries the security starter. This is a fixture in the test tree and reaches no + * deployment. + */ + @Bean + SecurityFilterChain pipelineFixtureSecurity(HttpSecurity http) throws Exception { + return http.csrf(csrf -> csrf.disable()) + .authorizeHttpRequests(requests -> requests.anyRequest().permitAll()) + .build(); + } +} diff --git a/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/webtestkit/ThrottleFixtureApplication.java b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/webtestkit/ThrottleFixtureApplication.java new file mode 100644 index 00000000..d6658e1e --- /dev/null +++ b/src/adapter/inbound/web/src/testkit/java/dev/caskeleton/webtestkit/ThrottleFixtureApplication.java @@ -0,0 +1,77 @@ +package dev.caskeleton.webtestkit; + +import dev.caskeleton.adapter.inbound.web.error.ThrottleProblemWriter; +import dev.caskeleton.adapter.inbound.web.error.WebProblemFactory; +import dev.caskeleton.adapter.inbound.web.json.WebObjectMapperFactory; +import dev.caskeleton.adapter.inbound.web.mvc.throttle.WebMvcThrottleFilter; +import dev.caskeleton.adapter.inbound.web.ratelimit.RateLimitFailurePolicy; +import dev.caskeleton.adapter.inbound.web.testkit.throttle.CountingRateLimiter; +import dev.caskeleton.adapter.inbound.web.testkit.throttle.ThrottleControlController; +import dev.caskeleton.adapter.inbound.web.testkit.throttle.ThrottleFixtureController; +import dev.caskeleton.adapter.inbound.web.testkit.throttle.ThrottleFixtureProtocol; +import dev.caskeleton.adapter.inbound.web.testkit.throttle.ThrottleFixtureSupport; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.core.Ordered; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.web.SecurityFilterChain; + +/** + * A servlet application with a tiny quota and a one-slot write profile. + * + *

The filter is registered for the throttled paths only. Registering it for everything would put + * the control route behind the very quota it exists to reset. + */ +// Outside `dev.caskeleton.adapter.inbound.web`, which the composition root component-scans. +// A fixture application under that root is loaded into the real deployment: its permit-all +// SecurityFilterChain shadows the real one and the context fails to start with an unreachable +// filter chain. @TestConfiguration would exclude it from scanning but is not accepted by +// @SpringBootTest(classes = ...), so the package is what keeps it out. +@Configuration(proxyBeanMethods = false) +@EnableAutoConfiguration +@Import({ThrottleFixtureController.class, ThrottleControlController.class}) +public class ThrottleFixtureApplication { + + /** The quota the lanes exhaust. */ + @Bean + CountingRateLimiter fixtureRateLimiter() { + return new CountingRateLimiter(); + } + + /** The throttle filter, on the throttled paths only. */ + @Bean + FilterRegistrationBean throttleFilter(CountingRateLimiter limiter) { + FilterRegistrationBean registration = + new FilterRegistrationBean<>( + new WebMvcThrottleFilter( + limiter, + ThrottleFixtureSupport.admissionController(), + (HttpServletRequest request) -> + ThrottleFixtureSupport.profileFor(request.getRequestURI()), + request -> ThrottleFixtureSupport.context(), + RateLimitFailurePolicy.FAIL_OPEN, + new ThrottleProblemWriter(WebProblemFactory.standard()), + WebObjectMapperFactory.standardJsonMapper())); + registration.addUrlPatterns( + ThrottleFixtureProtocol.QUOTA_PATH, ThrottleFixtureProtocol.WRITE_PATH); + registration.setOrder(Ordered.HIGHEST_PRECEDENCE); + return registration; + } + + /** + * Every fixture route is open. + * + *

The leaf carries the security starter, so without this the lane answers 401 and proves + * nothing about throttling. It is a fixture in the test tree and reaches no deployment. + */ + @Bean + SecurityFilterChain throttleFixtureSecurity(HttpSecurity http) throws Exception { + return http.csrf(csrf -> csrf.disable()) + .authorizeHttpRequests(requests -> requests.anyRequest().permitAll()) + .build(); + } +} diff --git a/src/adapter/inbound/web/src/testkit/resources/performance/web-load-profile.yaml b/src/adapter/inbound/web/src/testkit/resources/performance/web-load-profile.yaml new file mode 100644 index 00000000..2a935728 --- /dev/null +++ b/src/adapter/inbound/web/src/testkit/resources/performance/web-load-profile.yaml @@ -0,0 +1,33 @@ +# The load profile the release gate runs, and the bounds it must stay inside. +# +# The numbers are deliberately modest. This gate runs on developer machines and on CI runners whose +# capacity nobody controls, so a profile tuned to a production box would fail for reasons that have +# nothing to do with the change under review — and a gate that fails for unrelated reasons is a +# gate people learn to rerun until it passes. +# +# What it does check is shape, not scale: that latency has a tail rather than a cliff, that shed +# load is shed rather than queued, and that neither heap nor threads grow without bound. Those hold +# on any hardware; the absolute latencies do not. +profile: + name: standard + requests: 600 + concurrency: 64 + path: /api/v1/fixtures/f1 + +bounds: + # A p99 far above the median means the queue is doing something other than smoothing bursts. + p99: PT2S + # Peak heap during the run. Growth past this is the failure that has not happened yet: a latency + # profile bought with unbounded memory is not a passing result. + maxHeapBytes: 536870912 + # Nothing may error. Shedding is not an error — a 503 under load is the platform working — so + # rejected requests are counted separately and are permitted. + maxFailed: 0 + +abuse: + # Each of these is a request the platform must refuse cheaply rather than one it must serve. + slowClientConnections: 64 + floodConnections: 256 + oversizedBodyBytes: 4194304 + deepJsonNesting: 512 + hugeArrayElements: 200000 diff --git a/src/adapter/inbound/web/src/webfluxContractTest/java/dev/caskeleton/adapter/inbound/web/testkit/budget/ReactiveBudgetFixtureApplication.java b/src/adapter/inbound/web/src/webfluxContractTest/java/dev/caskeleton/adapter/inbound/web/testkit/budget/ReactiveBudgetFixtureApplication.java new file mode 100644 index 00000000..599a066e --- /dev/null +++ b/src/adapter/inbound/web/src/webfluxContractTest/java/dev/caskeleton/adapter/inbound/web/testkit/budget/ReactiveBudgetFixtureApplication.java @@ -0,0 +1,44 @@ +package dev.caskeleton.adapter.inbound.web.testkit.budget; + +import dev.caskeleton.adapter.inbound.web.error.BudgetProblemMapper; +import dev.caskeleton.adapter.inbound.web.error.WebProblemFactory; +import dev.caskeleton.adapter.inbound.web.json.WebObjectMapperFactory; +import dev.caskeleton.adapter.inbound.web.webflux.budget.WebFluxBudgetFilter; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; + +/** + * A reactive application with the same small budgets. + * + *

Lives in this lane rather than the shared testkit for the reason the reactive contract app + * does: this lane deliberately has no security starter on its classpath, so an application here + * cannot declare a permit-all chain and does not need one. + */ +// @Configuration, not @TestConfiguration: this source set is never on the application's runtime +// classpath, so nothing here can be component-scanned into a deployment — and @TestConfiguration +// types are not accepted by @SpringBootTest(classes = ...). +@Configuration(proxyBeanMethods = false) +@EnableAutoConfiguration +@Import(ReactiveBudgetFixtureController.class) +public class ReactiveBudgetFixtureApplication { + + /** + * The budget filter, ahead of everything that reads the request. + * + *

A {@code WebFilter} bean is ordered by {@code @Order} on the bean method; the precedence + * matters for the same reason as on the servlet side — a bound applied after another filter has + * consumed the body has already let the body in. + */ + @Bean + @Order(Ordered.HIGHEST_PRECEDENCE) + WebFluxBudgetFilter budgetFilter() { + return new WebFluxBudgetFilter( + BudgetFixtureProtocol.budget(), + new BudgetProblemMapper(WebProblemFactory.standard()), + WebObjectMapperFactory.standardJsonMapper()); + } +} diff --git a/src/adapter/inbound/web/src/webfluxContractTest/java/dev/caskeleton/adapter/inbound/web/testkit/budget/ReactiveWebBudgetIT.java b/src/adapter/inbound/web/src/webfluxContractTest/java/dev/caskeleton/adapter/inbound/web/testkit/budget/ReactiveWebBudgetIT.java new file mode 100644 index 00000000..4bfb455b --- /dev/null +++ b/src/adapter/inbound/web/src/webfluxContractTest/java/dev/caskeleton/adapter/inbound/web/testkit/budget/ReactiveWebBudgetIT.java @@ -0,0 +1,29 @@ +package dev.caskeleton.adapter.inbound.web.testkit.budget; + +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.test.context.ActiveProfiles; + +/** + * Budget enforcement on Reactor Netty. + * + *

The reactive stack is where the streaming requirement stops being advice: a body here is a + * publisher that may never be in memory whole, so a bound that needed to measure it would have to + * buffer the very request it exists to refuse. + */ +@SpringBootTest( + classes = ReactiveBudgetFixtureApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + // The budget handler is gated on this property. Turning it on here rather than defaulting it + // on keeps the production default off: a control that is on by default is one nobody notices. + properties = "backend.web.budgets.enabled=true") +@ActiveProfiles("web-contract") +class ReactiveWebBudgetIT extends WebBudgetContract { + + @LocalServerPort private int port; + + @Override + protected HttpBudgetFixture fixture() { + return new HttpBudgetFixture(port); + } +} diff --git a/src/adapter/inbound/web/src/webfluxContractTest/java/dev/caskeleton/adapter/inbound/web/testkit/contract/ReactiveContractRecordingIT.java b/src/adapter/inbound/web/src/webfluxContractTest/java/dev/caskeleton/adapter/inbound/web/testkit/contract/ReactiveContractRecordingIT.java new file mode 100644 index 00000000..b7c13ca2 --- /dev/null +++ b/src/adapter/inbound/web/src/webfluxContractTest/java/dev/caskeleton/adapter/inbound/web/testkit/contract/ReactiveContractRecordingIT.java @@ -0,0 +1,26 @@ +package dev.caskeleton.adapter.inbound.web.testkit.contract; + +import dev.caskeleton.adapter.inbound.web.testkit.webflux.ReactiveContractFixtureApplication; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.test.context.ActiveProfiles; + +/** Records the wire contract as Reactor Netty serves it. */ +@SpringBootTest( + classes = ReactiveContractFixtureApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("web-contract") +class ReactiveContractRecordingIT extends WebPlatformContractRecording { + + @LocalServerPort private int port; + + @Override + protected WebContractFixture fixture() { + return new WebContractFixture(port); + } + + @Override + protected String laneName() { + return "reactor-netty"; + } +} diff --git a/src/adapter/inbound/web/src/webfluxContractTest/java/dev/caskeleton/adapter/inbound/web/testkit/fault/ReactiveCommitThenConnectionResetIT.java b/src/adapter/inbound/web/src/webfluxContractTest/java/dev/caskeleton/adapter/inbound/web/testkit/fault/ReactiveCommitThenConnectionResetIT.java new file mode 100644 index 00000000..b7ec651a --- /dev/null +++ b/src/adapter/inbound/web/src/webfluxContractTest/java/dev/caskeleton/adapter/inbound/web/testkit/fault/ReactiveCommitThenConnectionResetIT.java @@ -0,0 +1,28 @@ +package dev.caskeleton.adapter.inbound.web.testkit.fault; + +import dev.caskeleton.adapter.inbound.web.testkit.webflux.ReactiveContractFixtureApplication; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.test.context.ActiveProfiles; + +/** + * The response-loss contract on Reactor Netty. + * + *

The reactive stack is where this guarantee is easiest to lose: the handler returns a publisher + * long before the response is written, so "the application committed" and "the client got an + * answer" are separated by a stretch of pipeline that has no equivalent in the servlet path. The + * assertions are the shared ones, so the two stacks are held to one contract rather than two. + */ +@SpringBootTest( + classes = ReactiveContractFixtureApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("web-contract") +class ReactiveCommitThenConnectionResetIT extends IdempotencyResponseLossContract { + + @LocalServerPort private int port; + + @Override + protected ResponseLossFixture fixture() { + return new HttpResponseLossFixture(port); + } +} diff --git a/src/adapter/inbound/web/src/webfluxContractTest/java/dev/caskeleton/adapter/inbound/web/testkit/operation/ReactiveOperationHttpIT.java b/src/adapter/inbound/web/src/webfluxContractTest/java/dev/caskeleton/adapter/inbound/web/testkit/operation/ReactiveOperationHttpIT.java new file mode 100644 index 00000000..234ae235 --- /dev/null +++ b/src/adapter/inbound/web/src/webfluxContractTest/java/dev/caskeleton/adapter/inbound/web/testkit/operation/ReactiveOperationHttpIT.java @@ -0,0 +1,27 @@ +package dev.caskeleton.adapter.inbound.web.testkit.operation; + +import dev.caskeleton.adapter.inbound.web.testkit.webflux.ReactiveContractFixtureApplication; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.test.context.ActiveProfiles; + +/** + * The operation resource contract on Reactor Netty. + * + *

The reactive controller builds its response inside a {@code map} rather than returning it + * directly, which is exactly where a conditional header is easiest to drop. The shared contract is + * what catches that without anybody having to remember to re-check it here. + */ +@SpringBootTest( + classes = ReactiveContractFixtureApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("web-contract") +class ReactiveOperationHttpIT extends OperationHttpContract { + + @LocalServerPort private int port; + + @Override + protected HttpOperationFixture fixture() { + return new HttpOperationFixture(port); + } +} diff --git a/src/adapter/inbound/web/src/webfluxContractTest/java/dev/caskeleton/adapter/inbound/web/testkit/order/ReactivePipelineOrderFixtureApplication.java b/src/adapter/inbound/web/src/webfluxContractTest/java/dev/caskeleton/adapter/inbound/web/testkit/order/ReactivePipelineOrderFixtureApplication.java new file mode 100644 index 00000000..5d34c578 --- /dev/null +++ b/src/adapter/inbound/web/src/webfluxContractTest/java/dev/caskeleton/adapter/inbound/web/testkit/order/ReactivePipelineOrderFixtureApplication.java @@ -0,0 +1,93 @@ +package dev.caskeleton.adapter.inbound.web.testkit.order; + +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.WebFilter; +import org.springframework.web.server.WebFilterChain; +import reactor.core.publisher.Mono; + +/** + * The reactive pipeline fixture. + * + *

The reactive stack has no ASYNC redispatch — every request is asynchronous and the chain runs + * once — so the duplicate-observation hazard takes a different shape here. Completion is recorded + * in {@code doFinally} on the returned publisher, because the request is over when the publisher + * terminates and not when {@code filter} returns. Recording at the end of the method would log a + * completion for a response still being written, which is the same wrong number arrived at from the + * opposite direction. + */ +// @Configuration, not @TestConfiguration: this source set is never on the application's runtime +// classpath, so nothing here can be component-scanned into a deployment — and @TestConfiguration +// types are not accepted by @SpringBootTest(classes = ...). +@Configuration(proxyBeanMethods = false) +@EnableAutoConfiguration +@Import(ReactivePipelineOrderFixtureController.class) +public class ReactivePipelineOrderFixtureApplication { + + private final WebPipelineRecorder recorder = new WebPipelineRecorder(); + + /** The recorder the fixture writes to. */ + @Bean + WebPipelineRecorder pipelineRecorder() { + return recorder; + } + + /** The pre-routing stages, and the completion. */ + @Bean + @Order(Ordered.HIGHEST_PRECEDENCE) + WebFilter pipelineEdgeFilter() { + return (ServerWebExchange exchange, WebFilterChain chain) -> { + String path = exchange.getRequest().getURI().getRawPath(); + if (isControlRoute(path)) { + return chain.filter(exchange); + } + recorder.record(WebPipelineStage.FORWARDED_NORMALIZATION); + recorder.record(WebPipelineStage.CORS); + recorder.record(WebPipelineStage.TRACE); + recorder.record(WebPipelineStage.AUTHENTICATION); + recorder.record(WebPipelineStage.GLOBAL_ADMISSION); + return chain + .filter(exchange) + .doFinally( + signal -> { + recorder.record(WebPipelineStage.RESPONSE_OR_PROBLEM); + recorder.record(WebPipelineStage.OBSERVATION); + }); + }; + } + + /** + * The route-scoped stages. + * + *

Ordered after the edge filter and registered as a second filter rather than an interceptor, + * because WebFlux has no {@code HandlerInterceptor}. It reads the handler out of the exchange + * attributes, which are populated by the handler mapping — so, as on the servlet side, reaching + * this point is itself the evidence that the route was selected. + */ + @Bean + @Order(Ordered.HIGHEST_PRECEDENCE + 1) + WebFilter pipelineRouteFilter() { + return (ServerWebExchange exchange, WebFilterChain chain) -> { + String path = exchange.getRequest().getURI().getRawPath(); + if (isControlRoute(path)) { + return chain.filter(exchange); + } + return Mono.fromRunnable( + () -> { + recorder.record(WebPipelineStage.ROUTE_SELECTED); + recorder.record(WebPipelineStage.ROUTE_POLICY); + }) + .then(chain.filter(exchange)); + }; + } + + private static boolean isControlRoute(String path) { + return path.startsWith(PipelineOrderFixtureController.OBSERVED_PATH) + || path.startsWith(PipelineOrderFixtureController.RESET_PATH); + } +} diff --git a/src/adapter/inbound/web/src/webfluxContractTest/java/dev/caskeleton/adapter/inbound/web/testkit/order/ReactivePipelineOrderFixtureController.java b/src/adapter/inbound/web/src/webfluxContractTest/java/dev/caskeleton/adapter/inbound/web/testkit/order/ReactivePipelineOrderFixtureController.java new file mode 100644 index 00000000..914c6c98 --- /dev/null +++ b/src/adapter/inbound/web/src/webfluxContractTest/java/dev/caskeleton/adapter/inbound/web/testkit/order/ReactivePipelineOrderFixtureController.java @@ -0,0 +1,87 @@ +package dev.caskeleton.adapter.inbound.web.testkit.order; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import java.time.Duration; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; + +/** + * The reactive routes the pipeline contract drives. + * + *

The "async" route defers onto another scheduler. On this stack every request is already + * asynchronous, so what this route adds is a thread hop between binding and the controller body — + * which is where a recorder tied to thread-local state, rather than to the request, would start + * losing or duplicating stages. + */ +@RestController +public class ReactivePipelineOrderFixtureController { + + /** A body with one constraint, so binding and validation genuinely happen. */ + public record PipelineRequest(@NotBlank String name) {} + + private final WebPipelineRecorder recorder; + + /** + * A controller over the recorder. + * + * @param recorder where stages are written + */ + public ReactivePipelineOrderFixtureController(WebPipelineRecorder recorder) { + this.recorder = recorder; + } + + /** Runs without a thread hop. */ + @PostMapping( + path = PipelineOrderFixtureController.SYNC_PATH, + consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE) + public Mono sync(@Valid @RequestBody PipelineRequest request) { + recorder.record(WebPipelineStage.BINDING_VALIDATION); + return Mono.fromSupplier( + () -> { + recorder.record(WebPipelineStage.CONTROLLER); + return "\"" + request.name() + "\""; + }); + } + + /** Runs after a thread hop and a delay. */ + @PostMapping( + path = PipelineOrderFixtureController.ASYNC_PATH, + consumes = MediaType.APPLICATION_JSON_VALUE, + produces = MediaType.APPLICATION_JSON_VALUE) + public Mono async(@Valid @RequestBody PipelineRequest request) { + recorder.record(WebPipelineStage.BINDING_VALIDATION); + return Mono.fromSupplier( + () -> { + recorder.record(WebPipelineStage.CONTROLLER); + return "\"" + request.name() + "\""; + }) + .delayElement(Duration.ofMillis(20)) + .subscribeOn(Schedulers.boundedElastic()); + } + + /** The stages observed so far. */ + @GetMapping( + path = PipelineOrderFixtureController.OBSERVED_PATH, + produces = MediaType.TEXT_PLAIN_VALUE) + public Mono observed() { + return Mono.fromSupplier( + () -> String.join(",", recorder.observed().stream().map(Enum::name).toList())); + } + + /** Clears the recorder. */ + @PostMapping(PipelineOrderFixtureController.RESET_PATH) + public Mono reset() { + return Mono.fromSupplier( + () -> { + recorder.reset(); + return ""; + }); + } +} diff --git a/src/adapter/inbound/web/src/webfluxContractTest/java/dev/caskeleton/adapter/inbound/web/testkit/order/WebFluxPipelineOrderIT.java b/src/adapter/inbound/web/src/webfluxContractTest/java/dev/caskeleton/adapter/inbound/web/testkit/order/WebFluxPipelineOrderIT.java new file mode 100644 index 00000000..cc94256d --- /dev/null +++ b/src/adapter/inbound/web/src/webfluxContractTest/java/dev/caskeleton/adapter/inbound/web/testkit/order/WebFluxPipelineOrderIT.java @@ -0,0 +1,27 @@ +package dev.caskeleton.adapter.inbound.web.testkit.order; + +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.test.context.ActiveProfiles; + +/** + * Pipeline order on Reactor Netty. + * + *

Same contract, different hazard. There is no redispatch here, so the way to observe a request + * twice — or zero times — is to tie completion to the filter method returning rather than to the + * publisher terminating. The shared cases catch both shapes without either lane having to describe + * the other's. + */ +@SpringBootTest( + classes = ReactivePipelineOrderFixtureApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("web-contract") +class WebFluxPipelineOrderIT extends WebPipelineOrderContract { + + @LocalServerPort private int port; + + @Override + protected HttpPipelineFixture fixture() { + return new HttpPipelineFixture(port); + } +} diff --git a/src/adapter/inbound/web/src/webfluxContractTest/java/dev/caskeleton/adapter/inbound/web/testkit/performance/ReactorNettyLoadAndShutdownIT.java b/src/adapter/inbound/web/src/webfluxContractTest/java/dev/caskeleton/adapter/inbound/web/testkit/performance/ReactorNettyLoadAndShutdownIT.java new file mode 100644 index 00000000..7ba4fa67 --- /dev/null +++ b/src/adapter/inbound/web/src/webfluxContractTest/java/dev/caskeleton/adapter/inbound/web/testkit/performance/ReactorNettyLoadAndShutdownIT.java @@ -0,0 +1,68 @@ +package dev.caskeleton.adapter.inbound.web.testkit.performance; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.inbound.web.testkit.webflux.ReactiveContractFixtureApplication; +import java.time.Duration; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.boot.web.server.reactive.context.ReactiveWebServerApplicationContext; +import org.springframework.test.context.ActiveProfiles; + +/** + * Load, abuse and graceful shutdown on Reactor Netty. + * + *

The slow-client case means something different here and is worth running for that reason. An + * event-loop server does not allocate a thread per connection, so half-open connections cost it far + * less — but they still consume file descriptors and buffers, and "cheaper" is not "free". The + * bound is what is being checked, not the mechanism. + */ +@SpringBootTest( + classes = ReactiveContractFixtureApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = "server.shutdown=graceful") +@ActiveProfiles("web-contract") +class ReactorNettyLoadAndShutdownIT extends WebLoadAndShutdownContract { + + @LocalServerPort private int port; + + @Autowired private ReactiveWebServerApplicationContext context; + + @Override + protected WebLoadFixture fixture() { + return new WebLoadFixture(port); + } + + @Override + protected String loadPath() { + return "/api/v1/fixtures/f1"; + } + + @Override + protected org.springframework.boot.web.server.WebServer webServer() { + return context.getWebServer(); + } + + @Test + @Tag("web-shutdown") + @DisplayName("graceful shutdown drains within the deployment's grace period") + void gracefulShutdownFinishesInFlightWork() { + // Last, and destructive: the context serves nothing afterwards. + WebLoadFixture fixture = fixture(); + assertThat(fixture.stillServing(loadPath())).isTrue(); + + GracefulShutdownProbe.Outcome outcome = + GracefulShutdownProbe.shutDown(webServer(), Duration.ofSeconds(10)); + + // Bounded on purpose. A shutdown that waits indefinitely for a connection to go idle is how a + // rolling deploy stalls with half the fleet drained — and it produces no error to alert on. + assertThat(outcome.took()).isLessThan(Duration.ofSeconds(15)); + assertThat(fixture.stillServing(loadPath())) + .as("the server accepted a new request after it was told to stop") + .isFalse(); + } +} diff --git a/src/adapter/inbound/web/src/webfluxContractTest/java/dev/caskeleton/adapter/inbound/web/testkit/streaming/ReactiveSseFixtureApplication.java b/src/adapter/inbound/web/src/webfluxContractTest/java/dev/caskeleton/adapter/inbound/web/testkit/streaming/ReactiveSseFixtureApplication.java new file mode 100644 index 00000000..7dc85dcc --- /dev/null +++ b/src/adapter/inbound/web/src/webfluxContractTest/java/dev/caskeleton/adapter/inbound/web/testkit/streaming/ReactiveSseFixtureApplication.java @@ -0,0 +1,81 @@ +package dev.caskeleton.adapter.inbound.web.testkit.streaming; + +import dev.caskeleton.adapter.inbound.web.advanced.stream.StreamId; +import dev.caskeleton.adapter.inbound.web.advanced.stream.StreamSequence; +import dev.caskeleton.adapter.inbound.web.advanced.stream.WebStreamEnvelope; +import dev.caskeleton.adapter.inbound.web.advanced.stream.WebStreamPolicy; +import dev.caskeleton.adapter.inbound.web.advanced.webflux.WebFluxSseAdapter; +import java.time.Duration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.MediaType; +import org.springframework.http.codec.ServerSentEvent; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; +import reactor.core.publisher.Flux; + +/** + * A reactive application that serves one SSE route through the Advanced adapter. + * + *

Exists so the adapter is exercised over a real socket rather than through {@code + * StepVerifier}. The two prove different things: the verifier proves the operator chain, and this + * proves that what reaches a client is an SSE stream a client can read — event names, ids, and a + * terminal event that actually arrives before the connection closes. + */ +@Configuration(proxyBeanMethods = false) +@EnableAutoConfiguration +public class ReactiveSseFixtureApplication { + + /** The stream every route here serves. */ + public static final StreamId STREAM = new StreamId("contract-feed"); + + /** How many items the finite route emits before completing. */ + public static final int ITEM_COUNT = 3; + + /** A short heartbeat so the keepalive is observable inside a test's patience. */ + static WebStreamPolicy policy() { + return new WebStreamPolicy( + Duration.ofMillis(200), Duration.ofSeconds(30), Duration.ofMinutes(5), 64, 262_144); + } + + // No @Bean for the controller below: a nested @RestController inside a @Configuration is + // registered by the configuration class itself, and declaring it again maps every route twice. + + /** Serves a finite stream and a quiet one. */ + @RestController + public static class ReactiveSseFixtureController { + + private final WebFluxSseAdapter adapter = new WebFluxSseAdapter(policy()); + + /** A stream that emits three items and then completes properly. */ + @GetMapping(path = "/contract/sse/finite", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public Flux>> finite() { + Flux> source = + Flux.range(1, ITEM_COUNT) + .map( + index -> + (WebStreamEnvelope) + new WebStreamEnvelope.Item<>( + STREAM, new StreamSequence(index), "item-" + index)) + .concatWith( + Flux.just( + new WebStreamEnvelope.Complete<>(STREAM, new StreamSequence(ITEM_COUNT)))); + return adapter.adapt(source); + } + + /** + * A stream that emits one item and then says nothing. + * + *

Never completes on its own, so what a client sees after the first item is entirely the + * heartbeat. That is the case a keepalive exists for and the one a finite stream cannot show. + */ + @GetMapping(path = "/contract/sse/quiet", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public Flux>> quiet() { + Flux> source = + Flux.>just( + new WebStreamEnvelope.Item<>(STREAM, StreamSequence.first(), "only")) + .concatWith(Flux.never()); + return adapter.adapt(source); + } + } +} diff --git a/src/adapter/inbound/web/src/webfluxContractTest/java/dev/caskeleton/adapter/inbound/web/testkit/streaming/WebFluxSseContractIT.java b/src/adapter/inbound/web/src/webfluxContractTest/java/dev/caskeleton/adapter/inbound/web/testkit/streaming/WebFluxSseContractIT.java new file mode 100644 index 00000000..e1328f9d --- /dev/null +++ b/src/adapter/inbound/web/src/webfluxContractTest/java/dev/caskeleton/adapter/inbound/web/testkit/streaming/WebFluxSseContractIT.java @@ -0,0 +1,120 @@ +package dev.caskeleton.adapter.inbound.web.testkit.streaming; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Stream; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; + +/** + * The reactive SSE adapter over a real socket. + * + *

{@code StepVerifier} proves the operator chain assembles correctly. It cannot prove that what + * arrives at a client is a readable {@code text/event-stream} — the framing, the event names, the + * ids a client sends back as {@code Last-Event-ID}, and above all that the terminal event actually + * reaches the wire before the connection closes. That last one is the whole contract: a client that + * sees a closed connection with no terminal event has been cut off, and it must be able to tell. + * + *

Reactor Netty, not a mock exchange. Chunked framing, flush timing and connection close on + * completion are all server code. + */ +@SpringBootTest( + classes = ReactiveSseFixtureApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class WebFluxSseContractIT { + + @LocalServerPort private int port; + + @Test + @DisplayName("a finite stream ends with a complete event and then closes") + void finiteStreamEndsWithACompleteEvent() throws Exception { + List lines = read("/contract/sse/finite", Duration.ofSeconds(20)); + + // The connection closed on its own, which only happens because the adapter stops at the + // terminal envelope. Without takeUntil the heartbeat would hold it for the full maximum age. + assertThat(eventsIn(lines)).containsExactly("item", "item", "item", "complete"); + } + + @Test + @DisplayName("only items carry an id, so a resume never continues a finished stream") + void onlyItemsCarryAnId() throws Exception { + List lines = read("/contract/sse/finite", Duration.ofSeconds(20)); + + // The ids a browser's EventSource will send back as Last-Event-ID. A terminal event carrying + // one would have a client ask to continue a stream that had already ended. + assertThat(idsIn(lines)).containsExactly("1", "2", "3"); + } + + @Test + @DisplayName("the payload is the envelope, readable as JSON") + void payloadIsTheEnvelope() throws Exception { + List lines = read("/contract/sse/finite", Duration.ofSeconds(20)); + + assertThat(lines).anyMatch(line -> line.startsWith("data:") && line.contains("item-1")); + assertThat(lines).anyMatch(line -> line.startsWith("data:") && line.contains("contract-feed")); + } + + @Test + @DisplayName("a quiet stream is kept alive by heartbeats rather than going silent") + void quietStreamIsKeptAlive() throws Exception { + // On a socket a silent connection and a dead one are the same thing, and the heartbeat is what + // separates them. Read for long enough that several beats must have been due. + List lines = read("/contract/sse/quiet", Duration.ofSeconds(3)); + + assertThat(eventsIn(lines)).contains("item"); + assertThat(lines) + .as("the keepalive is a comment line, which is what SSE defines for one") + .anyMatch(line -> line.startsWith(":") || line.contains("heartbeat")); + } + + /** + * Reads the stream until it closes or the budget runs out. + * + *

A budget rather than a completion wait, because one of these routes never completes — and a + * test that waited for completion on it would hang rather than fail. + */ + private List read(String path, Duration budget) throws Exception { + HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build(); + HttpRequest request = + HttpRequest.newBuilder(URI.create("http://localhost:" + port + path)) + .header("Accept", "text/event-stream") + .timeout(budget) + .GET() + .build(); + List lines = new ArrayList<>(); + HttpResponse> response = + client.send(request, HttpResponse.BodyHandlers.ofLines()); + assertThat(response.statusCode()).isEqualTo(200); + assertThat(response.headers().firstValue("content-type").orElse("")) + .startsWith("text/event-stream"); + long deadline = System.nanoTime() + budget.toNanos(); + var iterator = response.body().iterator(); + while (System.nanoTime() < deadline && iterator.hasNext()) { + lines.add(iterator.next()); + } + return lines; + } + + private static List eventsIn(List lines) { + return lines.stream() + .filter(line -> line.startsWith("event:")) + .map(line -> line.substring("event:".length()).trim()) + .toList(); + } + + private static List idsIn(List lines) { + return lines.stream() + .filter(line -> line.startsWith("id:")) + .map(line -> line.substring("id:".length()).trim()) + .toList(); + } +} diff --git a/src/adapter/inbound/web/src/webfluxContractTest/java/dev/caskeleton/adapter/inbound/web/testkit/throttle/ReactiveThrottleFixtureApplication.java b/src/adapter/inbound/web/src/webfluxContractTest/java/dev/caskeleton/adapter/inbound/web/testkit/throttle/ReactiveThrottleFixtureApplication.java new file mode 100644 index 00000000..c3eca519 --- /dev/null +++ b/src/adapter/inbound/web/src/webfluxContractTest/java/dev/caskeleton/adapter/inbound/web/testkit/throttle/ReactiveThrottleFixtureApplication.java @@ -0,0 +1,62 @@ +package dev.caskeleton.adapter.inbound.web.testkit.throttle; + +import dev.caskeleton.adapter.inbound.web.error.ThrottleProblemWriter; +import dev.caskeleton.adapter.inbound.web.error.WebProblemFactory; +import dev.caskeleton.adapter.inbound.web.json.WebObjectMapperFactory; +import dev.caskeleton.adapter.inbound.web.ratelimit.RateLimitFailurePolicy; +import dev.caskeleton.adapter.inbound.web.webflux.throttle.WebFluxThrottleFilter; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.core.Ordered; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.WebFilter; +import org.springframework.web.server.WebFilterChain; +import reactor.core.scheduler.Schedulers; + +/** + * The reactive throttle application. + * + *

The filter is wrapped in a path guard rather than registered for specific URLs, because a + * {@code WebFilter} has no URL patterns. Without the guard the control route would sit behind the + * quota it exists to reset, and every case after the first would fail for the wrong reason. + */ +// @Configuration, not @TestConfiguration: this source set is never on the application's runtime +// classpath, so nothing here can be component-scanned into a deployment — and @TestConfiguration +// types are not accepted by @SpringBootTest(classes = ...). +@Configuration(proxyBeanMethods = false) +@EnableAutoConfiguration +@Import({ThrottleFixtureController.class, ThrottleControlController.class}) +public class ReactiveThrottleFixtureApplication { + + /** The quota the lanes exhaust. */ + @Bean + CountingRateLimiter fixtureRateLimiter() { + return new CountingRateLimiter(); + } + + /** The throttle filter, applied to the throttled paths only. */ + @Bean + @org.springframework.core.annotation.Order(Ordered.HIGHEST_PRECEDENCE) + WebFilter throttleFilter(CountingRateLimiter limiter) { + WebFluxThrottleFilter throttle = + new WebFluxThrottleFilter( + limiter, + ThrottleFixtureSupport.admissionController(), + (ServerWebExchange exchange) -> + ThrottleFixtureSupport.profileFor(exchange.getRequest().getURI().getRawPath()), + exchange -> ThrottleFixtureSupport.context(), + RateLimitFailurePolicy.FAIL_OPEN, + new ThrottleProblemWriter(WebProblemFactory.standard()), + WebObjectMapperFactory.standardJsonMapper(), + Schedulers.boundedElastic()); + return (ServerWebExchange exchange, WebFilterChain chain) -> { + String path = exchange.getRequest().getURI().getRawPath(); + boolean throttled = + path.startsWith(ThrottleFixtureProtocol.QUOTA_PATH) + || path.startsWith(ThrottleFixtureProtocol.WRITE_PATH); + return throttled ? throttle.filter(exchange, chain) : chain.filter(exchange); + }; + } +} diff --git a/src/adapter/inbound/web/src/webfluxContractTest/java/dev/caskeleton/adapter/inbound/web/testkit/throttle/ReactiveWebThrottleIT.java b/src/adapter/inbound/web/src/webfluxContractTest/java/dev/caskeleton/adapter/inbound/web/testkit/throttle/ReactiveWebThrottleIT.java new file mode 100644 index 00000000..62e75401 --- /dev/null +++ b/src/adapter/inbound/web/src/webfluxContractTest/java/dev/caskeleton/adapter/inbound/web/testkit/throttle/ReactiveWebThrottleIT.java @@ -0,0 +1,42 @@ +package dev.caskeleton.adapter.inbound.web.testkit.throttle; + +import org.junit.jupiter.api.AfterEach; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.test.context.ActiveProfiles; + +/** + * Quota and capacity refusals on Reactor Netty. + * + *

This lane is where the permit-release contract is actually at risk. A reactive request is not + * over when the filter method returns; it is over when the publisher terminates. A permit released + * at the wrong one of those two moments hands the slot back while the request is still running, and + * the concurrency bound stops bounding anything — invisibly, until the service is loaded enough for + * it to matter. + */ +@SpringBootTest( + classes = ReactiveThrottleFixtureApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("web-contract") +class ReactiveWebThrottleIT extends WebThrottleHttpContract { + + @LocalServerPort private int port; + + private HttpThrottleFixture fixture; + + @Override + protected HttpThrottleFixture fixture() { + if (fixture == null) { + fixture = new HttpThrottleFixture(port); + } + return fixture; + } + + @AfterEach + void closeFixture() { + if (fixture != null) { + fixture.close(); + fixture = null; + } + } +} diff --git a/src/adapter/inbound/web/src/webfluxContractTest/java/dev/caskeleton/adapter/inbound/web/testkit/webflux/ReactiveContractFixtureApplication.java b/src/adapter/inbound/web/src/webfluxContractTest/java/dev/caskeleton/adapter/inbound/web/testkit/webflux/ReactiveContractFixtureApplication.java new file mode 100644 index 00000000..7e557f58 --- /dev/null +++ b/src/adapter/inbound/web/src/webfluxContractTest/java/dev/caskeleton/adapter/inbound/web/testkit/webflux/ReactiveContractFixtureApplication.java @@ -0,0 +1,54 @@ +package dev.caskeleton.adapter.inbound.web.testkit.webflux; + +import dev.caskeleton.adapter.inbound.web.error.ProblemCatalog; +import dev.caskeleton.adapter.inbound.web.error.WebProblemFactory; +import dev.caskeleton.adapter.inbound.web.error.WebProblemSanitizer; +import dev.caskeleton.adapter.inbound.web.testkit.fault.ReactiveFaultFixtureController; +import dev.caskeleton.adapter.inbound.web.testkit.operation.ReactiveOperationFixtureController; +import dev.caskeleton.adapter.inbound.web.webflux.error.WebFluxProblemExceptionHandler; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; + +/** + * The smallest reactive application that can serve the contract fixtures. + * + *

No component scan and no security starter on this lane's classpath. Both are deliberate: the + * gate is about the status contract on the wire, and a default security chain answering 401 would + * make every assertion here fail for a reason that has nothing to do with the contract. + * + *

Declaring a permit-all chain instead was tried and is worse. Boot's reactive security + * auto-configuration and {@code @EnableWebFluxSecurity} each register {@code + * conversionServicePostProcessor}, so having both fails the context before a route runs, and having + * only the auto-configuration leaves its default chain in place. Removing the dependency removes + * the question. + * + *

It lives in this lane's source set rather than the shared testkit for the same reason: the + * servlet gate needs a security chain and this one needs no security at all, so a shared fixture + * would have to satisfy both. + */ +// @Configuration, not @TestConfiguration: this source set is never on the application's runtime +// classpath, so nothing here can be component-scanned into a deployment — and @TestConfiguration +// types are not accepted by @SpringBootTest(classes = ...). +@Configuration(proxyBeanMethods = false) +@EnableAutoConfiguration +@Import({ + ReactiveContractFixtureController.class, + ReactiveFaultFixtureController.class, + ReactiveOperationFixtureController.class, + WebFluxProblemExceptionHandler.class +}) +public class ReactiveContractFixtureApplication { + + /** + * The problem catalog the exception handler publishes through. + * + *

The same catalog the servlet gate uses. Two catalogs would make the parity recording agree + * about a contract neither stack actually shares with a deployment. + */ + @Bean + WebProblemFactory contractProblemFactory() { + return new WebProblemFactory(ProblemCatalog.standard(), new WebProblemSanitizer()); + } +} diff --git a/src/adapter/inbound/web/src/webfluxContractTest/java/dev/caskeleton/adapter/inbound/web/testkit/webflux/ReactorNettyWebContractIT.java b/src/adapter/inbound/web/src/webfluxContractTest/java/dev/caskeleton/adapter/inbound/web/testkit/webflux/ReactorNettyWebContractIT.java new file mode 100644 index 00000000..c2717c7d --- /dev/null +++ b/src/adapter/inbound/web/src/webfluxContractTest/java/dev/caskeleton/adapter/inbound/web/testkit/webflux/ReactorNettyWebContractIT.java @@ -0,0 +1,128 @@ +package dev.caskeleton.adapter.inbound.web.testkit.webflux; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.context.ApplicationContext; +import org.springframework.test.context.ActiveProfiles; + +/** + * The Stable contract on a real Reactor Netty, over a real socket. + * + *

{@code WebTestClient.bindToController} is not enough to declare this Stable and the design + * says so: binding to a controller never starts a server, so it cannot show that the response left + * on an event loop, that a 204 wrote zero bytes, or that the Reactor Context survived the hop. Each + * of those is only observable from outside the process. + */ +@SpringBootTest( + classes = ReactiveContractFixtureApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("web-contract") +class ReactorNettyWebContractIT { + + @LocalServerPort private int port; + + @Autowired private ApplicationContext context; + + private final HttpClient client = + HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build(); + + @Test + @DisplayName("the lane is actually running on Reactor Netty") + void theLaneIsActuallyRunningOnReactorNetty() { + // The context type is the observation, not a bean lookup: a reactive application produces a + // reactive context, and a servlet one does not. Asserting the class name keeps the check + // independent of where Boot happens to have moved the concrete type. + assertThat(context.getClass().getName()) + .as("with Tomcat on the classpath Boot would start a servlet container and certify nothing") + .contains("Reactive"); + } + + @Test + @DisplayName("201 carries a Location and 204 writes zero body bytes") + void statusContractHoldsOnReactorNetty() throws Exception { + HttpResponse created = + client.send( + HttpRequest.newBuilder(uri("/api/v1/fixtures")) + .POST(HttpRequest.BodyPublishers.ofString("{\"name\":\"x\"}")) + .header("Content-Type", "application/json") + .build(), + HttpResponse.BodyHandlers.ofString()); + assertThat(created.statusCode()).isEqualTo(201); + assertThat(created.headers().firstValue("Location")).isPresent(); + + HttpResponse deleted = + client.send( + HttpRequest.newBuilder(uri("/api/v1/fixtures/f1")).DELETE().build(), + HttpResponse.BodyHandlers.ofByteArray()); + assertThat(deleted.statusCode()).isEqualTo(204); + assertThat(deleted.body()).isEmpty(); + } + + @Test + @DisplayName("the handler runs on an event loop and still sees its request context") + void theHandlerRunsOnAnEventLoopAndStillSeesItsContext() throws Exception { + HttpResponse report = + client.send( + HttpRequest.newBuilder(uri("/api/v1/fixtures/thread")).GET().build(), + HttpResponse.BodyHandlers.ofString()); + + assertThat(report.statusCode()).isEqualTo(200); + // The name the running server actually uses, checked against the guard's own predicate rather + // than against a literal: a literal here would go stale the same way the predicate did. + String threadName = report.body().replaceAll(".*\"threadName\":\"([^\"]+)\".*", "$1"); + assertThat( + dev.caskeleton.adapter.inbound.web.webflux.guard.BlockingDependencyGuard + .REACTOR_NETTY_EVENT_LOOP + .test(threadName)) + .as( + "the handler ran on %s, which the blocking guard must recognise as an event loop", + threadName) + .isTrue(); + assertThat(report.body()) + .as("a thread-local would have returned whichever request last ran on this loop") + .contains("requestId"); + assertThat(report.body()).contains("requestId"); + assertThat(report.headers().firstValue("X-Request-Id")).isPresent(); + } + + @Test + @DisplayName("HEAD parity holds and a rejected body leaks no internals") + void headParityHoldsAndARejectedBodyLeaksNoInternals() throws Exception { + HttpResponse get = + client.send( + HttpRequest.newBuilder(uri("/api/v1/fixtures/f1")).GET().build(), + HttpResponse.BodyHandlers.ofString()); + HttpResponse head = + client.send( + HttpRequest.newBuilder(uri("/api/v1/fixtures/f1")) + .method("HEAD", HttpRequest.BodyPublishers.noBody()) + .build(), + HttpResponse.BodyHandlers.ofByteArray()); + assertThat(head.headers().firstValue("ETag")).isEqualTo(get.headers().firstValue("ETag")); + assertThat(head.body()).isEmpty(); + + HttpResponse rejected = + client.send( + HttpRequest.newBuilder(uri("/api/v1/fixtures")) + .POST(HttpRequest.BodyPublishers.ofString("{\"name\":\" \"}")) + .header("Content-Type", "application/json") + .build(), + HttpResponse.BodyHandlers.ofString()); + assertThat(rejected.statusCode()).isBetween(400, 499); + assertThat(rejected.body()).doesNotContain("Exception").doesNotContain("dev.caskeleton"); + } + + private URI uri(String path) { + return URI.create("http://localhost:" + port + path); + } +} diff --git a/src/adapter/inbound/web/src/webfluxContractTest/resources/application-web-contract.yaml b/src/adapter/inbound/web/src/webfluxContractTest/resources/application-web-contract.yaml new file mode 100644 index 00000000..e5fcde1a --- /dev/null +++ b/src/adapter/inbound/web/src/webfluxContractTest/resources/application-web-contract.yaml @@ -0,0 +1,19 @@ +# The profile the real-container contract gate runs under. +# +# Everything is switched off except the servlet transport itself. The gate is about the status +# contract on the wire, and a security chain or a file-server profile joining the context would make +# a failure here ambiguous between "the contract broke" and "an unrelated capability did". +spring: + main: + banner-mode: "off" + mvc: + problemdetails: + enabled: true +server: + error: + include-stacktrace: never + include-message: never +backend: + web: + mvc: + enabled: true diff --git a/src/adapter/inbound/websocket/build.gradle b/src/adapter/inbound/websocket/build.gradle index 581692dd..93f7d9d6 100644 --- a/src/adapter/inbound/websocket/build.gradle +++ b/src/adapter/inbound/websocket/build.gradle @@ -13,17 +13,163 @@ description = 'Inbound adapter: WebSocket (STOMP over SockJS, skeleton machinery dependencies { implementation project(':domain-core') + // Reaches the realtime ports — the cluster registry and the replay window — whose + // implementations are outbound adapters this leaf may not name. The registry entry + // permits this edge; the datastore is on the other side of it. + implementation project(':application-core') implementation 'org.springframework.boot:spring-boot-starter-websocket' + // The reactive runtime's types only — deliberately not spring-boot-starter-webflux, which + // would put a second embedded server on the runtime classpath and make Boot deduce a reactive + // application. The two runtimes are mutually exclusive at deployment; both must nonetheless + // compile here, because the platform ships bindings for each. + implementation 'org.springframework:spring-webflux' + implementation 'io.projectreactor:reactor-core' implementation 'org.springframework.boot:spring-boot-starter-validation' + // The two Advanced binary codecs, compile-only. They were `implementation` first, so that an + // enabled codec would fail at startup rather than at the first frame. What that missed is that + // both jars are load-bearing for Spring Boot's auto-configuration by their mere presence: with + // the CBOR backend on the runtime classpath Boot registers a `cborMapper` bean, and with + // protobuf-java on it Spring registers a Protobuf message converter. A composition root that + // adopted this leaf would acquire both without enabling either capability — and the sibling web + // leaf demonstrated the consequence, where three ObjectMapper beans broke every + // `@Autowired ObjectMapper` in the composition root. + // + // So the deployment that turns a codec on adds its backend. `WebSocketBinaryCodecBackend` turns + // the absent jar into a sentence rather than a NoClassDefFoundError from inside Jackson. + compileOnly 'tools.jackson.dataformat:jackson-dataformat-cbor' + // Pinned: no BOM manages it. The version matches what the toolchain already resolves for the + // annotation processor, so the runtime and the processor cannot drift onto two protobuf + // runtimes with different wire behaviour. + compileOnly 'com.google.protobuf:protobuf-java:4.33.2' + testImplementation 'tools.jackson.dataformat:jackson-dataformat-cbor' + testImplementation 'com.google.protobuf:protobuf-java:4.33.2' annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' } +// The platform's reusable ArchUnit rules ship in their own source set, consumed by this leaf's +// tests and available to the composition root. A rule pack that only its own fixture tests import +// is verified as library code and applied to nothing. +strictTestLanes { + sourceSet('testkit') { compilesAgainst 'main' } + // Jetty replaces Tomcat for this lane only. With both on one classpath Boot starts Tomcat and + // the lane certifies the same container twice — which for a WebSocket matters more than for + // HTTP, because upgrade handling, close-frame timing and idle handling are all container code. + // The proxy lane runs the platform behind a real Nginx. Its own source set because it is the + // only lane that needs Docker; folded into `test`, every developer's `check` would depend on a + // container runtime and the usual end of that is a disabled test nobody notices. + sourceSet('nginxWebSocketTest') { + compilesAgainst 'main', 'testkit' + inherits 'implementation' + } + sourceSet('jettyWebSocketTest') { + compilesAgainst 'main', 'testkit' + inherits 'implementation' + } + // The STOMP broker lane. Its own source set for the same reason the Nginx one has its own: it + // is the only other lane that needs Docker, and folding it into `test` would make every + // developer's `check` depend on a container runtime. + sourceSet('brokerRelayTest') { + compilesAgainst 'main', 'testkit' + inherits 'implementation' + } +} + +testkitPublisher { + consumedBy 'test' + publishAs 'websocketTestkit' +} + +dependencies { + testkitImplementation 'com.tngtech.archunit:archunit-junit5:1.3.0' + testImplementation 'com.tngtech.archunit:archunit-junit5:1.3.0' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.boot:spring-boot-starter-websocket' + + jettyWebSocketTestImplementation('org.springframework.boot:spring-boot-starter-jetty') + jettyWebSocketTestImplementation('org.springframework.boot:spring-boot-starter-test') { + exclude group: 'org.springframework.boot', module: 'spring-boot-starter-tomcat' + } + jettyWebSocketTestImplementation('org.springframework.boot:spring-boot-starter-websocket') { + exclude group: 'org.springframework.boot', module: 'spring-boot-starter-tomcat' + } + jettyWebSocketTestRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + nginxWebSocketTestImplementation 'org.springframework.boot:spring-boot-starter-test' + nginxWebSocketTestImplementation 'org.springframework.boot:spring-boot-starter-websocket' + nginxWebSocketTestImplementation 'org.testcontainers:testcontainers' + nginxWebSocketTestImplementation 'org.testcontainers:testcontainers-junit-jupiter' + nginxWebSocketTestRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + brokerRelayTestImplementation 'org.springframework.boot:spring-boot-starter-test' + brokerRelayTestImplementation 'org.springframework.boot:spring-boot-starter-websocket' + brokerRelayTestImplementation 'org.testcontainers:testcontainers' + brokerRelayTestImplementation 'org.testcontainers:testcontainers-junit-jupiter' + brokerRelayTestImplementation 'org.testcontainers:testcontainers-rabbitmq' + brokerRelayTestRuntimeOnly 'org.junit.platform:junit-platform-launcher' +} + +// Docker-gated, and it fails rather than skipping. A proxy contract that quietly passes without a +// proxy has been certifying nothing since whenever the container runtime last broke. +tasks.register('websocketNginxTest', Test) { + group = 'verification' + description = 'Runs the upgrade and forwarded-header contract behind a real Nginx.' + testClassesDirs = sourceSets.nginxWebSocketTest.output.classesDirs + classpath = sourceSets.nginxWebSocketTest.runtimeClasspath + useJUnitPlatform() + failOnNoDiscoveredTests = true + outputs.upToDateWhen { false } + jvmArgs '-Duser.timezone=UTC' +} + +// Docker-gated, and it fails rather than skipping. A broker relay contract that quietly passes with +// no broker has been certifying nothing. +tasks.register('websocketBrokerRelayTest', Test) { + group = 'verification' + description = 'Runs the STOMP broker contract against the simple broker and a real RabbitMQ.' + testClassesDirs = sourceSets.brokerRelayTest.output.classesDirs + classpath = sourceSets.brokerRelayTest.runtimeClasspath + useJUnitPlatform() + failOnNoDiscoveredTests = true + outputs.upToDateWhen { false } + jvmArgs '-Duser.timezone=UTC' +} + +// The real-container lane. Upgrade negotiation, close-frame handling and idle behaviour are +// container code, so a mock dispatcher certifies none of it. +// The Advanced lane. Every capability is off unless a deployment names it, so nothing a production +// deployment runs exercises them — which makes this the only place a break is noticed before +// whoever enables it notices. They also run inside `test`: they are ordinary unit tests, and +// excluding them to make this lane look meaningful would stop the PR gate covering them. +tasks.register('websocketAdvancedTest', Test) { + group = 'verification' + description = 'Runs every WebSocket Advanced capability contract.' + testClassesDirs = sourceSets.test.output.classesDirs + classpath = sourceSets.test.runtimeClasspath + useJUnitPlatform { + includeTags 'websocket-advanced' + } + failOnNoDiscoveredTests = true + outputs.upToDateWhen { false } + jvmArgs '-Duser.timezone=UTC' +} + +tasks.register('websocketJettyTest', Test) { + group = 'verification' + description = 'Runs the WebSocket runtime contract against a real Jetty instead of Tomcat.' + testClassesDirs = sourceSets.jettyWebSocketTest.output.classesDirs + classpath = sourceSets.jettyWebSocketTest.runtimeClasspath + useJUnitPlatform() + failOnNoDiscoveredTests = true + outputs.upToDateWhen { false } + jvmArgs '-Duser.timezone=UTC' +} + registerStrictQualificationTest( name: 'websocketTransportQualificationTest', sourceSet: sourceSets.test, requiredClasses: [ - 'dev.caskeleton.adapter.inbound.websocket.WebSocketBoundaryQualificationTest' + 'dev.caskeleton.adapter.inbound.websocket.stomp.WebSocketBoundaryQualificationTest' ], description: 'Runs exact no-skip WebSocket conditional transport wire evidence.') diff --git a/src/adapter/inbound/websocket/gradle.lockfile b/src/adapter/inbound/websocket/gradle.lockfile index 59499a11..5c29e6ad 100644 --- a/src/adapter/inbound/websocket/gradle.lockfile +++ b/src/adapter/inbound/websocket/gradle.lockfile @@ -1,162 +1,222 @@ # This is a Gradle generated file for dependency locking. # Manual edits can break the build and are not advised. # This file is expected to be part of source control. -biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,testCompileClasspath -ch.qos.logback:logback-classic:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -ch.qos.logback:logback-core:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.fasterxml.jackson.core:jackson-annotations:2.20=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.fasterxml:classmate:1.7.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor -com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor +biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=brokerRelayTestCompileClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,nginxWebSocketTestCompileClasspath,testCompileClasspath,testkitCompileClasspath +ch.qos.logback:logback-classic:1.5.38=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +ch.qos.logback:logback-core:1.5.38=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +com.fasterxml.jackson.core:jackson-annotations:2.21=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +com.fasterxml:classmate:1.7.3=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,brokerRelayTestAnnotationProcessor,jettyWebSocketTestAnnotationProcessor,nginxWebSocketTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor +com.github.docker-java:docker-java-api:3.7.1=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath +com.github.docker-java:docker-java-transport-zerodep:3.7.1=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath +com.github.docker-java:docker-java-transport:3.7.1=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath +com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,brokerRelayTestAnnotationProcessor,jettyWebSocketTestAnnotationProcessor,nginxWebSocketTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs -com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,testCompileClasspath com.github.spotbugs:spotbugs:4.10.2=spotbugs com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs -com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor -com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor -com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor -com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,spotbugs,testCompileClasspath +com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,brokerRelayTestAnnotationProcessor,jettyWebSocketTestAnnotationProcessor,nginxWebSocketTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor +com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,brokerRelayTestAnnotationProcessor,jettyWebSocketTestAnnotationProcessor,nginxWebSocketTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor +com.google.auto:auto-common:1.2.2=annotationProcessor,brokerRelayTestAnnotationProcessor,jettyWebSocketTestAnnotationProcessor,nginxWebSocketTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor +com.google.code.findbugs:jsr305:3.0.2=checkstyle,spotbugs com.google.code.gson:gson:2.13.2=spotbugs -com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor -com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,testCompileClasspath +com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,brokerRelayTestAnnotationProcessor,jettyWebSocketTestAnnotationProcessor,nginxWebSocketTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor +com.google.errorprone:error_prone_annotations:2.38.0=brokerRelayTestCompileClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,nginxWebSocketTestCompileClasspath,testCompileClasspath,testkitCompileClasspath com.google.errorprone:error_prone_annotations:2.41.0=spotbugs com.google.errorprone:error_prone_annotations:2.47.0=checkstyle -com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor -com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor -com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor -com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor -com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor -com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor +com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,brokerRelayTestAnnotationProcessor,jettyWebSocketTestAnnotationProcessor,nginxWebSocketTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor +com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,brokerRelayTestAnnotationProcessor,jettyWebSocketTestAnnotationProcessor,nginxWebSocketTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor +com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,brokerRelayTestAnnotationProcessor,jettyWebSocketTestAnnotationProcessor,nginxWebSocketTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor +com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,brokerRelayTestAnnotationProcessor,jettyWebSocketTestAnnotationProcessor,nginxWebSocketTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor +com.google.guava:failureaccess:1.0.3=annotationProcessor,brokerRelayTestAnnotationProcessor,checkstyle,jettyWebSocketTestAnnotationProcessor,nginxWebSocketTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor +com.google.guava:guava:33.5.0-jre=annotationProcessor,brokerRelayTestAnnotationProcessor,jettyWebSocketTestAnnotationProcessor,nginxWebSocketTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor com.google.guava:guava:33.6.0-jre=checkstyle -com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,testAnnotationProcessor -com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor -com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor +com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,brokerRelayTestAnnotationProcessor,checkstyle,jettyWebSocketTestAnnotationProcessor,nginxWebSocketTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor +com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,brokerRelayTestAnnotationProcessor,checkstyle,jettyWebSocketTestAnnotationProcessor,nginxWebSocketTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor +com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,brokerRelayTestAnnotationProcessor,brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestAnnotationProcessor,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestAnnotationProcessor,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath,testkitAnnotationProcessor,testkitCompileClasspath,testkitRuntimeClasspath com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins -com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath +com.jayway.jsonpath:json-path:2.10.0=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath com.puppycrawl.tools:checkstyle:13.5.0=checkstyle -com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath +com.tngtech.archunit:archunit-junit5-api:1.3.0=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +com.tngtech.archunit:archunit-junit5-engine-api:1.3.0=brokerRelayTestRuntimeClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestRuntimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath +com.tngtech.archunit:archunit-junit5-engine:1.3.0=brokerRelayTestRuntimeClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestRuntimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath +com.tngtech.archunit:archunit-junit5:1.3.0=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +com.tngtech.archunit:archunit:1.3.0=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +com.vaadin.external.google:android-json:0.0.20131108.vaadin1=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath commons-beanutils:commons-beanutils:1.11.0=checkstyle +commons-codec:commons-codec:1.19.0=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath commons-collections:commons-collections:3.2.2=checkstyle +commons-io:commons-io:2.20.0=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath commons-io:commons-io:2.21.0=spotbugs -commons-logging:commons-logging:1.3.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +commons-logging:commons-logging:1.3.6=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath info.picocli:picocli:4.7.7=checkstyle -io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor -io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor -io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -jakarta.activation:jakarta.activation-api:2.1.4=testCompileClasspath,testRuntimeClasspath -jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -jakarta.validation:jakarta.validation-api:3.1.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=testCompileClasspath,testRuntimeClasspath -javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor -jaxen:jaxen:2.0.0=spotbugs -net.bytebuddy:byte-buddy-agent:1.17.8=testCompileClasspath,testRuntimeClasspath -net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath -net.minidev:accessors-smart:2.6.0=testCompileClasspath,testRuntimeClasspath -net.minidev:json-smart:2.6.0=testCompileClasspath,testRuntimeClasspath +io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,brokerRelayTestAnnotationProcessor,jettyWebSocketTestAnnotationProcessor,nginxWebSocketTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor +io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,brokerRelayTestAnnotationProcessor,jettyWebSocketTestAnnotationProcessor,nginxWebSocketTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor +io.micrometer:micrometer-commons:1.16.7=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +io.micrometer:micrometer-observation:1.16.7=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +io.projectreactor:reactor-core:3.8.7=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +jakarta.activation:jakarta.activation-api:2.1.4=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +jakarta.annotation:jakarta.annotation-api:3.0.0=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +jakarta.enterprise:jakarta.enterprise.cdi-api:4.1.0=jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath +jakarta.enterprise:jakarta.enterprise.lang-model:4.1.0=jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath +jakarta.inject:jakarta.inject-api:2.0.1=jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath +jakarta.interceptor:jakarta.interceptor-api:2.2.0=jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath +jakarta.servlet:jakarta.servlet-api:6.1.0=jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath +jakarta.transaction:jakarta.transaction-api:2.0.1=jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath +jakarta.validation:jakarta.validation-api:3.1.1=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +jakarta.websocket:jakarta.websocket-api:2.2.0=jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath +jakarta.websocket:jakarta.websocket-client-api:2.2.0=jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath +jakarta.xml.bind:jakarta.xml.bind-api:4.0.5=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +javax.inject:javax.inject:1=annotationProcessor,brokerRelayTestAnnotationProcessor,jettyWebSocketTestAnnotationProcessor,nginxWebSocketTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor +jaxen:jaxen:2.0.6=spotbugs +net.bytebuddy:byte-buddy-agent:1.17.8=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +net.bytebuddy:byte-buddy:1.17.8=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +net.java.dev.jna:jna:5.18.1=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath +net.minidev:accessors-smart:2.6.0=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +net.minidev:json-smart:2.6.0=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs org.antlr:antlr4-runtime:4.13.2=checkstyle org.apache.bcel:bcel:6.12.0=spotbugs -org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs +org.apache.commons:commons-compress:1.28.0=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath +org.apache.commons:commons-lang3:3.20.0=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,checkstyle,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,spotbugs org.apache.commons:commons-text:1.15.0=spotbugs org.apache.commons:commons-text:1.3=checkstyle org.apache.httpcomponents:httpclient:4.5.13=checkstyle org.apache.httpcomponents:httpcore:4.4.16=checkstyle -org.apache.logging.log4j:log4j-api:2.25.2=compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath -org.apache.logging.log4j:log4j-core:2.25.2=spotbugs -org.apache.logging.log4j:log4j-to-slf4j:2.25.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-api:2.25.5=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.apache.logging.log4j:log4j-core:2.25.5=spotbugs +org.apache.logging.log4j:log4j-to-slf4j:2.25.5=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath org.apache.maven.doxia:doxia-core:1.12.0=checkstyle org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle -org.apache.tomcat.embed:tomcat-embed-core:11.0.14=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.apache.tomcat.embed:tomcat-embed-el:11.0.14=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-core:11.0.24=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-el:11.0.24=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-websocket:11.0.24=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath org.apache.xbean:xbean-reflect:3.7=checkstyle -org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath -org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath -org.awaitility:awaitility:4.3.0=testCompileClasspath,testRuntimeClasspath +org.apiguardian:apiguardian-api:1.1.2=brokerRelayTestCompileClasspath,jettyWebSocketTestCompileClasspath,nginxWebSocketTestCompileClasspath,testCompileClasspath,testkitCompileClasspath +org.assertj:assertj-core:3.27.7=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.awaitility:awaitility:4.3.0=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle org.codehaus.plexus:plexus-utils:3.3.0=checkstyle org.dom4j:dom4j:2.2.0=spotbugs -org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath -org.hibernate.validator:hibernate-validator:9.0.1.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.eclipse.jetty.compression:jetty-compression-common:12.1.12=jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath +org.eclipse.jetty.compression:jetty-compression-gzip:12.1.12=jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath +org.eclipse.jetty.compression:jetty-compression-server:12.1.12=jettyWebSocketTestRuntimeClasspath +org.eclipse.jetty.ee11.websocket:jetty-ee11-websocket-jakarta-client:12.1.12=jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath +org.eclipse.jetty.ee11.websocket:jetty-ee11-websocket-jakarta-common:12.1.12=jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath +org.eclipse.jetty.ee11.websocket:jetty-ee11-websocket-jakarta-server:12.1.12=jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath +org.eclipse.jetty.ee11.websocket:jetty-ee11-websocket-jetty-server:12.1.12=jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath +org.eclipse.jetty.ee11.websocket:jetty-ee11-websocket-servlet:12.1.12=jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath +org.eclipse.jetty.ee11:jetty-ee11-annotations:12.1.12=jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath +org.eclipse.jetty.ee11:jetty-ee11-plus:12.1.12=jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath +org.eclipse.jetty.ee11:jetty-ee11-servlet:12.1.12=jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath +org.eclipse.jetty.ee11:jetty-ee11-webapp:12.1.12=jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath +org.eclipse.jetty.ee:jetty-ee-webapp:12.1.12=jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath +org.eclipse.jetty.websocket:jetty-websocket-core-client:12.1.12=jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath +org.eclipse.jetty.websocket:jetty-websocket-core-common:12.1.12=jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath +org.eclipse.jetty.websocket:jetty-websocket-core-server:12.1.12=jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath +org.eclipse.jetty.websocket:jetty-websocket-jetty-api:12.1.12=jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath +org.eclipse.jetty.websocket:jetty-websocket-jetty-common:12.1.12=jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath +org.eclipse.jetty.websocket:jetty-websocket-jetty-server:12.1.12=jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath +org.eclipse.jetty:jetty-alpn-client:12.1.12=jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath +org.eclipse.jetty:jetty-annotations:12.1.12=jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath +org.eclipse.jetty:jetty-client:12.1.12=jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath +org.eclipse.jetty:jetty-http:12.1.12=jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath +org.eclipse.jetty:jetty-io:12.1.12=jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath +org.eclipse.jetty:jetty-plus:12.1.12=jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath +org.eclipse.jetty:jetty-security:12.1.12=jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath +org.eclipse.jetty:jetty-server:12.1.12=jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath +org.eclipse.jetty:jetty-session:12.1.12=jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath +org.eclipse.jetty:jetty-util:12.1.12=jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath +org.eclipse.jetty:jetty-xml:12.1.12=jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath +org.hamcrest:hamcrest:3.0=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.hibernate.validator:hibernate-validator:9.0.1.Final=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath org.javassist:javassist:3.28.0-GA=checkstyle -org.jboss.logging:jboss-logging:3.6.1.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath -org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-engine:6.0.1=testRuntimeClasspath -org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath -org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath +org.jboss.logging:jboss-logging:3.6.3.Final=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.jetbrains:annotations:17.0.0=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath +org.jspecify:jspecify:1.0.1=annotationProcessor,brokerRelayTestAnnotationProcessor,brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,checkstyle,compileClasspath,jettyWebSocketTestAnnotationProcessor,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestAnnotationProcessor,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath,testkitAnnotationProcessor,testkitCompileClasspath,testkitRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:6.0.3=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.3=brokerRelayTestRuntimeClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestRuntimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.3=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.3=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.3=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.3=brokerRelayTestRuntimeClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestRuntimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.3=brokerRelayTestRuntimeClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestRuntimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath +org.junit:junit-bom:6.0.3=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath org.junit:junit-bom:6.1.0=spotbugs -org.mockito:mockito-core:5.20.0=mockitoAgent,testCompileClasspath,testRuntimeClasspath -org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath -org.objenesis:objenesis:3.3=testRuntimeClasspath -org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath -org.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,testCompileClasspath -org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,testCompileClasspath -org.osgi:org.osgi.resource:1.0.0=compileClasspath,testCompileClasspath -org.osgi:org.osgi.service.serviceloader:1.0.0=compileClasspath,testCompileClasspath +org.mockito:mockito-core:5.20.0=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,mockitoAgent,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.mockito:mockito-junit-jupiter:5.20.0=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.objenesis:objenesis:3.3=brokerRelayTestRuntimeClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestRuntimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath +org.opentest4j:opentest4j:1.3.0=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.osgi:org.osgi.annotation.bundle:2.0.0=brokerRelayTestCompileClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,nginxWebSocketTestCompileClasspath,testCompileClasspath,testkitCompileClasspath +org.osgi:org.osgi.annotation.versioning:1.1.2=brokerRelayTestCompileClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,nginxWebSocketTestCompileClasspath,testCompileClasspath,testkitCompileClasspath +org.osgi:org.osgi.resource:1.0.0=brokerRelayTestCompileClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,nginxWebSocketTestCompileClasspath,testCompileClasspath,testkitCompileClasspath +org.osgi:org.osgi.service.serviceloader:1.0.0=brokerRelayTestCompileClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,nginxWebSocketTestCompileClasspath,testCompileClasspath,testkitCompileClasspath org.ow2.asm:asm-analysis:9.10.1=spotbugs -org.ow2.asm:asm-commons:9.10.1=spotbugs -org.ow2.asm:asm-tree:9.10.1=spotbugs +org.ow2.asm:asm-commons:9.10.1=jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,spotbugs +org.ow2.asm:asm-tree:9.10.1=jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,spotbugs org.ow2.asm:asm-util:9.10.1=spotbugs -org.ow2.asm:asm:9.10.1=spotbugs -org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath -org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor +org.ow2.asm:asm:9.10.1=jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,spotbugs +org.ow2.asm:asm:9.7.1=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.pcollections:pcollections:4.0.1=annotationProcessor,brokerRelayTestAnnotationProcessor,jettyWebSocketTestAnnotationProcessor,nginxWebSocketTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor +org.reactivestreams:reactive-streams:1.0.4=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath org.reflections:reflections:0.10.2=checkstyle -org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath -org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath -org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j -org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-configuration-processor:4.0.0=annotationProcessor -org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-http-converter:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-jackson:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-restclient:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-servlet:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-jackson:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-tomcat:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-validation:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-webmvc:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-websocket:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-tomcat:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-validation:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-web-server:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-webmvc:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-websocket:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-aop:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-beans:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-context:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-core:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-expression:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-messaging:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-test:7.0.1=testCompileClasspath,testRuntimeClasspath -org.springframework:spring-web:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-webmvc:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-websocket:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.rnorth.duct-tape:duct-tape:1.0.8=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath +org.skyscreamer:jsonassert:1.5.3=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.slf4j:jul-to-slf4j:2.0.18=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.slf4j:slf4j-api:2.0.18=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.18=checkstyle +org.springframework.boot:spring-boot-autoconfigure:4.0.8=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-configuration-processor:4.0.8=annotationProcessor +org.springframework.boot:spring-boot-http-converter:4.0.8=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-jackson:4.0.8=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-jetty:4.0.8=jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath +org.springframework.boot:spring-boot-resttestclient:4.0.8=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-servlet:4.0.8=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-starter-jackson-test:4.0.8=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-starter-jackson:4.0.8=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-starter-jetty-runtime:4.0.8=jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath +org.springframework.boot:spring-boot-starter-jetty:4.0.8=jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath +org.springframework.boot:spring-boot-starter-logging:4.0.8=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-starter-test:4.0.8=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.8=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat:4.0.8=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-starter-validation:4.0.8=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-starter-webmvc-test:4.0.8=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-starter-webmvc:4.0.8=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-starter-websocket:4.0.8=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-starter:4.0.8=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-test-autoconfigure:4.0.8=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-test:4.0.8=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-tomcat:4.0.8=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-validation:4.0.8=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-web-server:4.0.8=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-webmvc-test:4.0.8=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-webmvc:4.0.8=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-websocket:4.0.8=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot:4.0.8=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework:spring-aop:7.0.9=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework:spring-beans:7.0.9=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework:spring-context:7.0.9=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework:spring-core:7.0.9=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework:spring-expression:7.0.9=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework:spring-messaging:7.0.9=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework:spring-test:7.0.9=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework:spring-web:7.0.9=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework:spring-webflux:7.0.9=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework:spring-webmvc:7.0.9=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework:spring-websocket:7.0.9=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.testcontainers:testcontainers-junit-jupiter:2.0.5=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath +org.testcontainers:testcontainers-rabbitmq:2.0.5=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath +org.testcontainers:testcontainers:2.0.5=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs -org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath -org.yaml:snakeyaml:2.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -tools.jackson.core:jackson-core:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -tools.jackson.core:jackson-databind:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -tools.jackson:jackson-bom:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.xmlunit:xmlunit-core:2.10.4=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.yaml:snakeyaml:2.5=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +tools.jackson.core:jackson-core:3.1.5=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +tools.jackson.core:jackson-databind:3.1.5=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +tools.jackson.dataformat:jackson-dataformat-cbor:3.1.5=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +tools.jackson:jackson-bom:3.1.5=brokerRelayTestCompileClasspath,brokerRelayTestRuntimeClasspath,compileClasspath,jettyWebSocketTestCompileClasspath,jettyWebSocketTestRuntimeClasspath,nginxWebSocketTestCompileClasspath,nginxWebSocketTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath empty= diff --git a/src/adapter/inbound/websocket/src/brokerRelayTest/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/StompBrokerContractTest.java b/src/adapter/inbound/websocket/src/brokerRelayTest/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/StompBrokerContractTest.java new file mode 100644 index 00000000..17eb9a4b Binary files /dev/null and b/src/adapter/inbound/websocket/src/brokerRelayTest/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/StompBrokerContractTest.java differ diff --git a/src/adapter/inbound/websocket/src/jettyWebSocketTest/java/dev/caskeleton/adapter/inbound/websocket/runtime/JettyWebSocketRuntimeIT.java b/src/adapter/inbound/websocket/src/jettyWebSocketTest/java/dev/caskeleton/adapter/inbound/websocket/runtime/JettyWebSocketRuntimeIT.java new file mode 100644 index 00000000..4f646426 --- /dev/null +++ b/src/adapter/inbound/websocket/src/jettyWebSocketTest/java/dev/caskeleton/adapter/inbound/websocket/runtime/JettyWebSocketRuntimeIT.java @@ -0,0 +1,42 @@ +package dev.caskeleton.adapter.inbound.websocket.runtime; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.inbound.websocket.testkit.runtime.WebSocketFixtureApplication; +import dev.caskeleton.adapter.inbound.websocket.testkit.runtime.WebSocketRuntimeContract; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.boot.web.server.servlet.context.ServletWebServerApplicationContext; + +/** + * The same runtime contract on Jetty. + * + *

Worth more here than for plain HTTP. Upgrade negotiation, close-frame delivery and the + * treatment of a partially received message are each implemented independently by the two + * containers, and the places they differ are exactly the places this contract asserts. + */ +@SpringBootTest( + classes = WebSocketFixtureApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class JettyWebSocketRuntimeIT extends WebSocketRuntimeContract { + + @LocalServerPort private int port; + + @Autowired private ServletWebServerApplicationContext context; + + @Override + protected int port() { + return port; + } + + @Test + @DisplayName("the lane is actually running on Jetty") + void theLaneIsActuallyRunningOnJetty() { + // With Tomcat still on the classpath this lane would certify the same container twice — the + // failure a compatibility matrix exists to prevent and is worst at detecting. + assertThat(context.getWebServer().getClass().getName()).contains("Jetty"); + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/admin/WebSocketAdminOperations.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/admin/WebSocketAdminOperations.java new file mode 100644 index 00000000..2bb8a67f --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/admin/WebSocketAdminOperations.java @@ -0,0 +1,52 @@ +package dev.caskeleton.adapter.inbound.websocket.admin; + +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketActorReference; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketConnectionId; +import java.time.Duration; + +/** + * What an operator may do to live connections. + * + *

Three operations, and every one of them is an override of a control the platform put in place + * — which is why they belong in an audit trail rather than an access log, and why each requires a + * stated reason. + * + *

Disconnecting by actor rather than only by connection is the operation that is actually + * needed. An incident response is "cut this account off", and doing that connection by connection + * means the operator races the client's reconnects. + */ +public interface WebSocketAdminOperations { + + /** What this node is currently doing. */ + WebSocketNodeSnapshot snapshot(); + + /** + * Closes one connection. + * + * @param connectionId which connection + * @param reason why, recorded in the audit trail + * @return whether this node held it + */ + boolean disconnect(WebSocketConnectionId connectionId, String reason); + + /** + * Closes every connection an actor holds on this node. + * + * @param actor whose connections + * @param reason why, recorded in the audit trail + * @return how many were closed + */ + int disconnectActor(WebSocketActorReference actor, String reason); + + /** + * Stops accepting new connections and drains the existing ones. + * + *

Bounded by {@code drainTimeout}, because a peer that has stopped reading will not finish + * draining and a deploy cannot wait for it. + * + * @param drainTimeout how long in-flight work is given + * @param reason why, recorded in the audit trail + * @return how many connections were asked to drain + */ + int drain(Duration drainTimeout, String reason); +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/admin/WebSocketNodeSnapshot.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/admin/WebSocketNodeSnapshot.java new file mode 100644 index 00000000..8c43e13e --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/admin/WebSocketNodeSnapshot.java @@ -0,0 +1,60 @@ +package dev.caskeleton.adapter.inbound.websocket.admin; + +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * What one node is currently doing, as an operator can read it. + * + *

Node-scoped and it says so. During an incident the first question is "which node", and a + * snapshot that silently aggregated the fleet would answer it wrong — the operator drains a node + * and the number does not move, because the number was never about that node. + * + *

{@code droppedOutboundMessages} is the field that earns its place. Connection counts and + * buffered bytes both look healthy while a slow consumer quietly loses data; the drop count is the + * only number that says so. + * + * @param nodeId which node + * @param connectionsByEndpoint how many connections each endpoint holds here + * @param totalConnections how many this node holds + * @param bufferedOutboundBytes how much unsent data is held + * @param peakBufferedOutboundBytes the most ever held at once + * @param droppedOutboundMessages how many messages were discarded for slow consumers + * @param draining whether this node is shutting down + */ +public record WebSocketNodeSnapshot( + String nodeId, + Map connectionsByEndpoint, + int totalConnections, + long bufferedOutboundBytes, + long peakBufferedOutboundBytes, + long droppedOutboundMessages, + boolean draining) { + + public WebSocketNodeSnapshot { + Objects.requireNonNull(nodeId, "nodeId"); + Objects.requireNonNull(connectionsByEndpoint, "connectionsByEndpoint"); + connectionsByEndpoint = Map.copyOf(connectionsByEndpoint); + if (totalConnections < 0 || bufferedOutboundBytes < 0 || droppedOutboundMessages < 0) { + throw new IllegalArgumentException("snapshot counters cannot be negative"); + } + } + + /** Whether this node is losing outbound messages. */ + public boolean losingMessages() { + return droppedOutboundMessages > 0; + } + + /** + * The endpoints holding connections, busiest first. + * + *

Ordered because an operator reading a snapshot during an incident is looking for the + * outlier, and an unordered map makes them scan. + */ + public List> byBusiest() { + return connectionsByEndpoint.entrySet().stream() + .sorted(Map.Entry.comparingByValue().reversed()) + .toList(); + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/WebSocketAdvancedCapability.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/WebSocketAdvancedCapability.java new file mode 100644 index 00000000..5668e66f --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/WebSocketAdvancedCapability.java @@ -0,0 +1,95 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced; + +import java.util.Locale; + +/** + * The capabilities that are not part of Stable, each behind its own flag. + * + *

One flag per capability rather than one for "advanced", because they have nothing in common + * operationally. Enabling resume adds a token and a store; enabling a Redis fan-out adds a network + * dependency to the delivery path; enabling STOMP adds a second protocol parser reachable from an + * unauthenticated frame. A single switch would make those one decision, and a deployment that + * wanted the first would be given the third. + * + *

Every constant is off unless named. A capability that defaulted on would be in production + * before anyone reviewed what it costs, and several of these change the failure modes of the whole + * platform rather than adding a feature beside it. + */ +public enum WebSocketAdvancedCapability { + + /** + * Resuming a session across a reconnect from a stored position. + * + *

Changes what a gap means: with resume, a client that missed messages can ask for them, so a + * gap becomes recoverable rather than a resynchronise. Without the replay store behind it the + * token is a promise the platform cannot keep. + */ + RESUME, + + /** A cluster-wide session index in Redis, so a node can find where an actor is connected. */ + CLUSTER_REDIS, + + /** Durable cross-node fan-out over the messaging platform. */ + CLUSTER_MESSAGING, + + /** Presence summaries derived from the cluster index. */ + PRESENCE, + + /** The STOMP 1.2 subprotocol, alongside the platform's own. */ + STOMP, + + /** A broker relay to RabbitMQ for STOMP destinations. */ + BROKER_RELAY_RABBIT, + + /** Protobuf as a wire codec. */ + CODEC_PROTOBUF, + + /** CBOR as a wire codec. */ + CODEC_CBOR, + + /** + * The {@code permessage-deflate} extension. + * + *

Per endpoint and never global. Compression on a connection that carries attacker-influenced + * data alongside secrets is the CRIME/BREACH shape, and the decision needs the endpoint's content + * in view. + */ + COMPRESSION, + + /** An outbound WebSocket client, for connecting to somebody else's endpoint. */ + OUTBOUND_CLIENT, + + /** SockJS fallback for clients that cannot use a raw WebSocket. */ + SOCKJS_COMPAT, + + /** HTTP/2 extended CONNECT. */ + HTTP2_COMPAT, + + /** HTTP/3 WebSocket, experimental. */ + HTTP3_EXPERIMENTAL, + + /** The GraphQL-over-WebSocket transport. */ + GRAPHQL_TRANSPORT; + + /** The property that enables this capability. */ + public String propertyName() { + return "backend.websocket.advanced." + + name().toLowerCase(Locale.ROOT).replace('_', '-') + + ".enabled"; + } + + /** + * Whether this capability changes the platform's failure modes rather than adding beside them. + * + *

Read by the startup validator, which requires these to be named in the deployment's own + * configuration rather than inherited from a profile. Turning on a network dependency in the + * delivery path is not the same kind of decision as turning on a codec. + */ + public boolean altersFailureModes() { + return this == RESUME + || this == CLUSTER_REDIS + || this == CLUSTER_MESSAGING + || this == BROKER_RELAY_RABBIT + || this == HTTP3_EXPERIMENTAL; + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/client/NamedClientProfile.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/client/NamedClientProfile.java new file mode 100644 index 00000000..f10afc9d --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/client/NamedClientProfile.java @@ -0,0 +1,79 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.client; + +import java.net.URI; +import java.time.Duration; +import java.util.Objects; +import java.util.Optional; + +/** + * One outbound WebSocket connection this service makes to somebody else. + * + *

Named and declared, rather than opened wherever a URL happens to be in scope. An outbound + * WebSocket is a long-lived connection to a third party: it holds a thread or an event-loop + * registration for its whole life, it re-authenticates on every reconnect, and when the peer goes + * away it is this service's resource usage that changes, not the peer's. Naming them makes the set + * of such connections something an operator can enumerate. + * + *

The subprotocol is pinned rather than negotiated freely for the same reason it is pinned on + * the inbound side: a peer that answers with a subprotocol this client did not ask for has changed + * the message contract, and continuing means parsing its frames with the wrong reader. + * + * @param name the operator-facing identity + * @param uri where to connect + * @param subprotocol the one subprotocol offered, absent when none is + * @param connectTimeout how long the handshake may take + * @param idleTimeout how long a silent connection is kept + */ +public record NamedClientProfile( + String name, + URI uri, + Optional subprotocol, + Duration connectTimeout, + Duration idleTimeout) { + + public NamedClientProfile { + Objects.requireNonNull(name, "name"); + Objects.requireNonNull(uri, "uri"); + Objects.requireNonNull(subprotocol, "subprotocol"); + Objects.requireNonNull(connectTimeout, "connectTimeout"); + Objects.requireNonNull(idleTimeout, "idleTimeout"); + if (name.isBlank()) { + throw new IllegalArgumentException("an unnamed outbound connection cannot be found in a log"); + } + String scheme = uri.getScheme(); + if (scheme == null || !(scheme.equals("ws") || scheme.equals("wss"))) { + throw new IllegalArgumentException("not a WebSocket URI: " + uri); + } + if (connectTimeout.isNegative() || connectTimeout.isZero()) { + throw new IllegalArgumentException( + "an unbounded handshake holds a connection slot against a peer that is not answering"); + } + if (idleTimeout.isNegative() || idleTimeout.isZero()) { + throw new IllegalArgumentException( + "without an idle timeout a half-open connection to a vanished peer is indistinguishable " + + "from a quiet one, and is held forever"); + } + } + + /** Whether the transport is encrypted. */ + public boolean secure() { + return "wss".equals(uri.getScheme()); + } + + /** + * What this profile exposes before it is fit for anything but a developer's machine. + * + *

Reported rather than refused: connecting to a local peer over plaintext during development + * is legitimate, and the startup validator is where the deployment's profiles are in view. + */ + public Optional plaintextConcern() { + if (secure()) { + return Optional.empty(); + } + return Optional.of( + "outbound connection '" + + name + + "' is plaintext; its credentials and every message it carries are readable on the " + + "path"); + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/client/NamedClientRegistry.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/client/NamedClientRegistry.java new file mode 100644 index 00000000..61d68c9d --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/client/NamedClientRegistry.java @@ -0,0 +1,60 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.client; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +/** + * The outbound connections this service is allowed to make. + * + *

A closed set, resolved by name. The alternative — a client that takes a URI — makes the set of + * peers this service talks to a property of whatever string reached the call site, which is how a + * service ends up holding a connection to somewhere nobody intended and nobody can enumerate. + */ +public final class NamedClientRegistry { + + private final Map profiles; + + private NamedClientRegistry(Map profiles) { + this.profiles = Map.copyOf(profiles); + } + + /** Build a registry from declared profiles. */ + public static NamedClientRegistry of(List profiles) { + Objects.requireNonNull(profiles, "profiles"); + Map byName = new LinkedHashMap<>(); + for (NamedClientProfile profile : profiles) { + NamedClientProfile previous = byName.putIfAbsent(profile.name(), profile); + if (previous != null) { + throw new IllegalArgumentException( + "two outbound connections named '" + + profile.name() + + "'; whichever was registered last would silently win and the other peer would " + + "never be reached"); + } + } + return new NamedClientRegistry(byName); + } + + /** Resolve a name. Empty for anything undeclared, which the caller must treat as a refusal. */ + public Optional find(String name) { + return Optional.ofNullable(profiles.get(name)); + } + + /** Every declared name, for the startup report. */ + public List names() { + return profiles.keySet().stream().sorted().toList(); + } + + /** Every plaintext connection, for the startup validator to judge against the active profiles. */ + public List plaintextConcerns() { + return profiles.values().stream().flatMap(p -> p.plaintextConcern().stream()).sorted().toList(); + } + + /** How many connections are declared. */ + public int size() { + return profiles.size(); + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/client/ReconnectPolicy.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/client/ReconnectPolicy.java new file mode 100644 index 00000000..cb347961 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/client/ReconnectPolicy.java @@ -0,0 +1,92 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.client; + +import java.time.Duration; +import java.util.Objects; +import java.util.OptionalLong; +import java.util.random.RandomGenerator; + +/** + * How an outbound client waits before trying again. + * + *

Not an HTTP retry policy. An HTTP retry re-sends one request; a WebSocket reconnect + * re-establishes a session, which means a handshake, an authentication, a resubscribe, and — if the + * peer is a service with many clients — it happens to every one of them at the same instant. That + * synchronised wave is the failure mode: a peer that restarts is met by its entire client + * population reconnecting in lockstep, which keeps it down. + * + *

Hence jitter, and hence bounded attempts. A client that retries forever against a peer that + * has been decommissioned is a permanent load source that nobody notices because each individual + * attempt looks reasonable. + * + * @param initialBackoff the first wait + * @param maxBackoff the ceiling the exponential growth stops at + * @param multiplier how fast the wait grows + * @param maxAttempts how many times to try before giving up, or empty for unbounded + * @param jitterRatio how much of each wait is randomised, 0.0 to 1.0 + */ +public record ReconnectPolicy( + Duration initialBackoff, + Duration maxBackoff, + double multiplier, + OptionalLong maxAttempts, + double jitterRatio) { + + public ReconnectPolicy { + Objects.requireNonNull(initialBackoff, "initialBackoff"); + Objects.requireNonNull(maxBackoff, "maxBackoff"); + Objects.requireNonNull(maxAttempts, "maxAttempts"); + if (initialBackoff.isNegative() || initialBackoff.isZero()) { + throw new IllegalArgumentException( + "a zero first backoff reconnects in a tight loop against a peer that just refused"); + } + if (maxBackoff.compareTo(initialBackoff) < 0) { + throw new IllegalArgumentException("the backoff ceiling is below its floor"); + } + if (multiplier < 1.0) { + throw new IllegalArgumentException( + "a multiplier below 1 shortens the wait as failures pile up"); + } + if (jitterRatio <= 0.0 || jitterRatio > 1.0) { + throw new IllegalArgumentException( + "jitter must be positive: without it every client of a restarted peer reconnects in " + + "lockstep and keeps it down"); + } + if (maxAttempts.isPresent() && maxAttempts.getAsLong() < 1) { + throw new IllegalArgumentException("a bounded policy must permit at least one attempt"); + } + } + + /** The conventional policy: 1s to 60s, doubling, full jitter, 20 attempts. */ + public static ReconnectPolicy conventional() { + return new ReconnectPolicy( + Duration.ofSeconds(1), Duration.ofSeconds(60), 2.0, OptionalLong.of(20), 1.0); + } + + /** Whether another attempt is permitted. */ + public boolean mayRetry(long attemptsSoFar) { + return maxAttempts.isEmpty() || attemptsSoFar < maxAttempts.getAsLong(); + } + + /** + * How long to wait before attempt {@code attempt}, counting from 1. + * + * @param attempt which attempt this is + * @param random the source of jitter, supplied so the calculation is testable + */ + public Duration backoffFor(long attempt, RandomGenerator random) { + Objects.requireNonNull(random, "random"); + if (attempt < 1) { + throw new IllegalArgumentException("attempts count from 1"); + } + double base = initialBackoff.toMillis() * Math.pow(multiplier, attempt - 1D); + long capped = (long) Math.min(base, (double) maxBackoff.toMillis()); + long jitterSpan = (long) (capped * jitterRatio); + if (jitterSpan <= 0) { + return Duration.ofMillis(capped); + } + // Subtracted rather than added, so the ceiling is a ceiling. Adding jitter on top of maxBackoff + // means the configured maximum is not the maximum, which is the kind of off-by-a-factor that + // only shows up when the peer stays down for an hour. + return Duration.ofMillis(capped - random.nextLong(jitterSpan + 1)); + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/cluster/ExternalSessionIndex.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/cluster/ExternalSessionIndex.java new file mode 100644 index 00000000..51e1d958 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/cluster/ExternalSessionIndex.java @@ -0,0 +1,52 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.cluster; + +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketActorReference; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketEndpointName; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketNodeId; +import java.time.Duration; +import java.time.Instant; +import java.util.List; + +/** + * A cluster-wide view of where actors are connected. + * + *

Two expiry mechanisms, and both are needed. A TTL on each entry removes what a node forgot to + * clean up; a node heartbeat removes everything a node held when it died. Neither covers the + * other's case — a TTL alone leaves a dead node's entries live until each one individually expires, + * and a heartbeat alone leaves a live node's stale entries for ever. + * + *

Nothing here is authoritative. The index says where a connection was last reported, and the + * node holding it is the only thing that knows whether it still exists. + */ +public interface ExternalSessionIndex { + + /** + * Records that a node holds connections for an actor. + * + * @param summary what the node observed + * @param timeToLive how long the entry survives without being refreshed + */ + void announce(ExternalSessionSummary summary, Duration timeToLive); + + /** Removes an actor's entry for one node. */ + void withdraw( + WebSocketActorReference actor, WebSocketEndpointName endpoint, WebSocketNodeId nodeId); + + /** + * Where an actor is reported connected. + * + * @param now the instant staleness is judged against + */ + List locate( + WebSocketActorReference actor, WebSocketEndpointName endpoint, Instant now); + + /** Records that a node is alive. */ + void heartbeat(WebSocketNodeId nodeId, Instant now); + + /** + * Removes everything held by nodes that have stopped reporting. + * + * @return the nodes whose entries were removed + */ + List evictDeadNodes(Duration heartbeatTimeout, Instant now); +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/cluster/ExternalSessionSummary.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/cluster/ExternalSessionSummary.java new file mode 100644 index 00000000..6a4e96a9 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/cluster/ExternalSessionSummary.java @@ -0,0 +1,64 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.cluster; + +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketActorReference; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketEndpointName; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketNodeId; +import java.time.Duration; +import java.time.Instant; +import java.util.Objects; + +/** + * Where an actor is connected, as seen from outside the node holding the connection. + * + *

Every entry carries when it was observed, and callers are expected to use it. A cluster index + * is a cache of a fact owned by another machine: the node may have died, the connection may have + * closed, and nothing tells the index until a TTL expires. Treating an entry as current is how a + * message is routed to a node that stopped existing three minutes ago. + * + *

The index key is the actor's fingerprint, never a subject or tenant. Redis keys reach {@code + * MONITOR}, slow-log entries, `KEYS` output during an incident, and whatever backup the cluster + * takes — none of which have the access controls the application has. + * + * @param actor whose connection this is + * @param nodeId which node holds it + * @param endpoint which endpoint it connected to + * @param connectionCount how many connections that actor holds on that node + * @param observedAt when the holding node last said so + */ +public record ExternalSessionSummary( + WebSocketActorReference actor, + WebSocketNodeId nodeId, + WebSocketEndpointName endpoint, + int connectionCount, + Instant observedAt) { + + public ExternalSessionSummary { + Objects.requireNonNull(actor, "actor"); + Objects.requireNonNull(nodeId, "nodeId"); + Objects.requireNonNull(endpoint, "endpoint"); + Objects.requireNonNull(observedAt, "observedAt"); + if (connectionCount < 0) { + throw new IllegalArgumentException("a connection count cannot be negative"); + } + } + + /** + * The index key for an actor and endpoint. + * + *

Fingerprint only. A key carrying a subject is a subject in every Redis diagnostic anyone + * runs, and those run during incidents when the audience is widest. + */ + public static String indexKey(WebSocketActorReference actor, WebSocketEndpointName endpoint) { + return "ws:idx:" + endpoint.value() + ":" + actor.fingerprint(); + } + + /** + * Whether this entry is too old to act on. + * + * @param maximumAge how stale an entry may be before it is treated as absent + * @param now the current instant + */ + public boolean staleAt(Duration maximumAge, Instant now) { + return !now.isBefore(observedAt.plus(maximumAge)); + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/cluster/FanoutDeduplicator.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/cluster/FanoutDeduplicator.java new file mode 100644 index 00000000..f5ed4f9d --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/cluster/FanoutDeduplicator.java @@ -0,0 +1,100 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.cluster; + +import java.time.Duration; +import java.time.Instant; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Refuses a message the receiving node has already delivered. + * + *

Necessary because durable fan-out is at-least-once by construction: a consumer that fails to + * acknowledge — a redeploy, a slow node, a broker rebalance — has its messages redelivered, and + * redelivery is normal operation rather than an error. Without deduplication a rolling restart + * shows every connected client a burst of repeated events, which for anything the client appends to + * a list is visible corruption. + * + *

Keyed by stream and position rather than by a message id, because the position is what the + * client actually orders on. A redelivery with a fresh message id and the same position is the case + * a message-id cache misses. + */ +public final class FanoutDeduplicator { + + private final Map highWaterMark = new ConcurrentHashMap<>(); + private final Map lastSeen = new ConcurrentHashMap<>(); + private final Duration retention; + + /** + * A deduplicator that forgets streams it has not seen for a while. + * + * @param retention how long a stream's position is remembered after its last message + */ + public FanoutDeduplicator(Duration retention) { + this.retention = Objects.requireNonNull(retention, "retention"); + if (retention.isZero() || retention.isNegative()) { + throw new IllegalArgumentException( + "a deduplicator that forgets immediately deduplicates nothing"); + } + } + + /** + * Whether this message should be delivered. + * + * @param envelope the message + * @param now the current instant + */ + public boolean shouldDeliver(FanoutEnvelope envelope, Instant now) { + Objects.requireNonNull(envelope, "envelope"); + if (envelope.expiredAt(now)) { + // Checked here as well as at publication. A message that sat through an outage is delivered + // late, and for ephemeral traffic late is indistinguishable from wrong. + return false; + } + if (!envelope.ordered()) { + // Nothing to deduplicate against. An unordered fan-out is best-effort by declaration, and + // inventing a key for it would silently drop legitimately repeated values. + return true; + } + String stream = envelope.streamId().orElseThrow(); + long position = envelope.sequence().orElseThrow(); + lastSeen.put(stream, now); + // compute(), so the read and the update are one step. An earlier version used merge() with + // Math::max and read the result back, which cannot distinguish "this raised the mark" from + // "this equalled it" — so a redelivery of the current position was delivered again, which is + // precisely the case redelivery produces most often. + boolean[] deliver = new boolean[1]; + highWaterMark.compute( + stream, + (id, mark) -> { + if (mark == null || position > mark) { + deliver[0] = true; + return position; + } + deliver[0] = false; + return mark; + }); + return deliver[0]; + } + + /** Forgets streams that have gone quiet, so the map does not grow with churn. */ + public int evictIdle(Instant now) { + int before = lastSeen.size(); + lastSeen + .entrySet() + .removeIf( + entry -> { + if (!now.isBefore(entry.getValue().plus(retention))) { + highWaterMark.remove(entry.getKey()); + return true; + } + return false; + }); + return before - lastSeen.size(); + } + + /** How many streams are being tracked. */ + public int trackedStreams() { + return highWaterMark.size(); + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/cluster/FanoutEnvelope.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/cluster/FanoutEnvelope.java new file mode 100644 index 00000000..2e5d5c34 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/cluster/FanoutEnvelope.java @@ -0,0 +1,75 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.cluster; + +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketActorReference; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketEndpointName; +import dev.caskeleton.adapter.inbound.websocket.protocol.WebSocketMessageType; +import java.time.Instant; +import java.util.Objects; +import java.util.Optional; + +/** + * A message crossing nodes on its way to a connection. + * + *

Carries a reference and a type, not the business object. Two reasons, and the second is the + * one that bites later: a fan-out payload is serialized by whichever node published it and + * deserialized by whichever node holds the connection, so during a rolling deploy those are + * different versions of the code — the payload's shape becomes a wire contract between two + * deployments of the same service, and nobody writes it down. Keeping it an already-encoded + * document, encoded against the published catalog, makes it the same contract the client sees + * rather than a second private one. + * + *

The first reason is simpler: a business object on a bus is a business object in the bus's + * storage, its dead-letter queue, and its management console. + * + * @param targetActor whose connections should receive it + * @param endpoint which endpoint's connections + * @param type the published message type + * @param payload the encoded document, already valid against the catalog + * @param streamId the stream it belongs to, for an ordered delivery + * @param sequence its position on that stream + * @param publishedAt when the originating node published it + * @param expiresAt when it stops being worth delivering + */ +public record FanoutEnvelope( + WebSocketActorReference targetActor, + WebSocketEndpointName endpoint, + WebSocketMessageType type, + String payload, + Optional streamId, + Optional sequence, + Instant publishedAt, + Instant expiresAt) { + + public FanoutEnvelope { + Objects.requireNonNull(targetActor, "targetActor"); + Objects.requireNonNull(endpoint, "endpoint"); + Objects.requireNonNull(type, "type"); + Objects.requireNonNull(payload, "payload"); + Objects.requireNonNull(streamId, "streamId"); + Objects.requireNonNull(sequence, "sequence"); + Objects.requireNonNull(publishedAt, "publishedAt"); + Objects.requireNonNull(expiresAt, "expiresAt"); + if (!expiresAt.isAfter(publishedAt)) { + throw new IllegalArgumentException("a fan-out that expires when published delivers nothing"); + } + if (streamId.isPresent() != sequence.isPresent()) { + throw new IllegalArgumentException("a stream id and a sequence are meaningless apart"); + } + } + + /** + * Whether this message is still worth delivering. + * + *

Checked on the receiving node, not only on the sending one. A message that sat in a queue + * through an outage is delivered late to a client that has moved on, and for the ephemeral + * traffic fan-out carries — presence changes, live values — late is indistinguishable from wrong. + */ + public boolean expiredAt(Instant now) { + return !now.isBefore(expiresAt); + } + + /** Whether this message participates in an ordered stream. */ + public boolean ordered() { + return streamId.isPresent(); + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/cluster/MessagingFanoutAdapter.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/cluster/MessagingFanoutAdapter.java new file mode 100644 index 00000000..0d107aa1 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/cluster/MessagingFanoutAdapter.java @@ -0,0 +1,102 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.cluster; + +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketActorReference; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketEndpointName; +import dev.caskeleton.adapter.inbound.websocket.protocol.WebSocketMessageType; +import dev.caskeleton.application.realtime.DurableFanoutPort; +import dev.caskeleton.application.realtime.DurableFanoutRecord; +import dev.caskeleton.application.realtime.RealtimeChannel; +import java.time.Instant; +import java.util.Objects; +import java.util.Optional; + +/** + * Publishes a {@link FanoutEnvelope} through the durable fan-out port, and reads one back. + * + *

Two representations of the same message, and they are separate on purpose. {@link + * FanoutEnvelope} is this leaf's vocabulary — it names an endpoint and a message type from the + * platform's catalog — while {@link DurableFanoutRecord} is what crosses the broker. Collapsing + * them would put transport types in {@code application-core}, and it would also mean a change to + * either one's shape was a change to the wire format between nodes. + * + *

The partition key is the actor's fingerprint. Keying on the endpoint would put a whole feed in + * one partition, which serialises every recipient behind the slowest and removes the parallelism + * the broker was chosen for; keying on the actor also gives per-recipient ordering, which is the + * only ordering this fan-out promises. + * + *

The expiry is carried and re-checked on receipt. A durable transport is exactly the one that + * can hold a message through an outage and deliver it afterwards, and the receiving node is the + * only place that knows what time it is when that happens. + */ +public final class MessagingFanoutAdapter { + + private final DurableFanoutPort fanout; + + public MessagingFanoutAdapter(DurableFanoutPort fanout) { + this.fanout = Objects.requireNonNull(fanout, "fanout"); + } + + /** + * Publish an envelope for whichever node holds its recipient. + * + * @throws dev.caskeleton.application.realtime.DurableFanoutUnavailableException when the + * transport would not accept it + */ + public void publish(FanoutEnvelope envelope) { + Objects.requireNonNull(envelope, "envelope"); + fanout.publish( + new DurableFanoutRecord( + new RealtimeChannel(envelope.endpoint().value()), + envelope.targetActor().fingerprint(), + envelope.payload(), + envelope.streamId(), + envelope.sequence(), + envelope.publishedAt(), + envelope.expiresAt())); + } + + /** + * Read a received record back into this leaf's vocabulary. + * + * @param record what arrived + * @param actor the recipient, resolved by the receiving node rather than carried on the wire + * @param type the catalog type the payload was encoded as + * @return the envelope, or empty when the record cannot be represented here + */ + public Optional receive( + DurableFanoutRecord record, WebSocketActorReference actor, WebSocketMessageType type) { + Objects.requireNonNull(record, "record"); + Objects.requireNonNull(actor, "actor"); + Objects.requireNonNull(type, "type"); + try { + return Optional.of( + new FanoutEnvelope( + actor, + new WebSocketEndpointName(record.channel().value()), + type, + record.payload(), + record.streamId(), + record.position(), + record.publishedAt(), + record.expiresAt())); + } catch (IllegalArgumentException unrepresentable) { + // A record whose channel is not an endpoint this build knows about. Dropped rather than + // raised: during a rolling deploy the other half of the cluster may be publishing for a feed + // this node has not learned yet, and failing the consumer would stop it processing the + // records it does understand. + Objects.requireNonNull(unrepresentable); + return Optional.empty(); + } + } + + /** + * Whether a received record should be delivered at all. + * + * @param record what arrived + * @param now the receiving node's clock + */ + public static boolean deliverable(DurableFanoutRecord record, Instant now) { + Objects.requireNonNull(record, "record"); + return !record.expiredAt(now); + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/cluster/PortBackedExternalSessionIndex.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/cluster/PortBackedExternalSessionIndex.java new file mode 100644 index 00000000..300c9b38 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/cluster/PortBackedExternalSessionIndex.java @@ -0,0 +1,108 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.cluster; + +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketActorReference; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketEndpointName; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketNodeId; +import dev.caskeleton.application.realtime.ActorFingerprint; +import dev.caskeleton.application.realtime.ConnectionRegistration; +import dev.caskeleton.application.realtime.ConnectionRegistryPort; +import dev.caskeleton.application.realtime.RealtimeChannel; +import dev.caskeleton.application.realtime.RealtimeNodeId; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Objects; + +/** + * The cluster session index, on the application's connection registry. + * + *

This class is a translator and nothing else. The registry lives in an outbound adapter — Redis + * today — and this leaf may depend on {@code application-core} and no further, so the datastore is + * reached through a port and the transport's own vocabulary stops here. + * + *

That boundary is not ceremony. {@link WebSocketEndpointName} is a URL path, and a URL path + * changes when somebody renames a route; {@link RealtimeChannel} is a logical feed name that does + * not. Mapping between them here means renaming an endpoint does not invalidate every entry the + * cluster is holding, and the channel a deployment stores is a decision rather than a side effect + * of its routing table. + * + *

Nothing here fails loudly. The port degrades to "nothing found" by contract, and this + * preserves that: a lookup that returns empty makes the caller broadcast, which is correct, and an + * exception would turn a Redis blip into a failed delivery. + */ +public final class PortBackedExternalSessionIndex implements ExternalSessionIndex { + + private final ConnectionRegistryPort registry; + + public PortBackedExternalSessionIndex(ConnectionRegistryPort registry) { + this.registry = Objects.requireNonNull(registry, "registry"); + } + + @Override + public void announce(ExternalSessionSummary summary, Duration timeToLive) { + Objects.requireNonNull(summary, "summary"); + Objects.requireNonNull(timeToLive, "timeToLive"); + registry.announce( + new ConnectionRegistration( + fingerprint(summary.actor()), + nodeId(summary.nodeId()), + channel(summary.endpoint()), + summary.connectionCount(), + summary.observedAt()), + timeToLive); + } + + @Override + public void withdraw( + WebSocketActorReference actor, WebSocketEndpointName endpoint, WebSocketNodeId nodeId) { + registry.withdraw(fingerprint(actor), channel(endpoint), nodeId(nodeId)); + } + + @Override + public List locate( + WebSocketActorReference actor, WebSocketEndpointName endpoint, Instant now) { + Objects.requireNonNull(actor, "actor"); + Objects.requireNonNull(endpoint, "endpoint"); + Objects.requireNonNull(now, "now"); + return registry.locate(fingerprint(actor), channel(endpoint), now).stream() + .map( + registration -> + new ExternalSessionSummary( + actor, + new WebSocketNodeId(registration.nodeId().value()), + endpoint, + registration.connectionCount(), + registration.observedAt())) + .toList(); + } + + @Override + public void heartbeat(WebSocketNodeId nodeId, Instant now) { + registry.heartbeat(nodeId(nodeId), now); + } + + @Override + public List evictDeadNodes(Duration heartbeatTimeout, Instant now) { + return registry.evictSilentNodes(heartbeatTimeout, now).stream() + .map(evicted -> new WebSocketNodeId(evicted.value())) + .toList(); + } + + private static ActorFingerprint fingerprint(WebSocketActorReference actor) { + Objects.requireNonNull(actor, "actor"); + // The reference is already a hash of subject and tenant under a deployment salt, so this is a + // rename rather than a pseudonymisation. The port's type refuses anything that is not, which is + // what stops a later change here from passing a raw subject through. + return new ActorFingerprint(actor.fingerprint()); + } + + private static RealtimeNodeId nodeId(WebSocketNodeId nodeId) { + Objects.requireNonNull(nodeId, "nodeId"); + return new RealtimeNodeId(nodeId.value()); + } + + private static RealtimeChannel channel(WebSocketEndpointName endpoint) { + Objects.requireNonNull(endpoint, "endpoint"); + return new RealtimeChannel(endpoint.value()); + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/cluster/ReplayCursor.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/cluster/ReplayCursor.java new file mode 100644 index 00000000..14045c87 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/cluster/ReplayCursor.java @@ -0,0 +1,64 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.cluster; + +import java.util.Objects; + +/** + * Where a replay has got to on one stream. + * + *

Exclusive of {@code from} and inclusive of {@code through}, stated because an off-by-one here + * is a duplicate or a hole in somebody's history and neither announces itself. The client said it + * saw position N, so a replay starts at N+1. + * + * @param streamId which stream + * @param from the first position to deliver + * @param through the last position delivered so far + */ +public record ReplayCursor(String streamId, long from, long through) { + + public ReplayCursor { + Objects.requireNonNull(streamId, "streamId"); + if (streamId.isBlank()) { + throw new IllegalArgumentException("a cursor needs the stream it is on"); + } + if (from < 1) { + // Positions start at 1. A cursor from 0 asks for a message that never existed, and the + // source would answer with the first real one — silently shifting every position after it. + throw new IllegalArgumentException("replay starts at position 1, not " + from); + } + if (through < from - 1) { + throw new IllegalArgumentException("a cursor cannot have delivered less than nothing"); + } + } + + /** A cursor about to deliver its first message. */ + public static ReplayCursor startingAt(String streamId, long from) { + return new ReplayCursor(streamId, from, from - 1); + } + + /** The same cursor after delivering one position. */ + public ReplayCursor advancedTo(long position) { + if (position != through + 1) { + // Advancing by more than one would skip a position with nothing recording that it was + // skipped, which is the hole this type exists to make impossible. + throw new IllegalArgumentException( + "a cursor at " + + through + + " cannot advance to " + + position + + "; it would skip " + + (position - through - 1) + + " positions with nothing recording that it did"); + } + return new ReplayCursor(streamId, from, position); + } + + /** How many positions have been delivered. */ + public long delivered() { + return through - from + 1; + } + + /** Whether the cursor has reached a target position. */ + public boolean caughtUpTo(long target) { + return through >= target; + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/cluster/ReplayEventMapper.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/cluster/ReplayEventMapper.java new file mode 100644 index 00000000..4a1d8b21 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/cluster/ReplayEventMapper.java @@ -0,0 +1,132 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.cluster; + +import dev.caskeleton.adapter.inbound.websocket.protocol.WebSocketMessageCatalog; +import dev.caskeleton.adapter.inbound.websocket.protocol.WebSocketMessageType; +import java.time.Instant; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +/** + * Turns a broker message into a replay record, or refuses it. + * + *

The refusal is the point. A replay path that forwards whatever the broker holds is a path on + * which a message written by an older deployment, or by a producer that was never meant to reach + * this endpoint, arrives at a client as though the platform had published it. Retained messages + * outlive deployments by definition — that is what retention is — so "the producer and consumer + * agree" is exactly the assumption that does not hold here. + * + *

Every mapped record is therefore checked against the same catalog that governs live traffic: + * the type must be published, must be server-to-client, and must be at a schema major this + * deployment still serves. + */ +public final class ReplayEventMapper { + + /** Why a broker message could not become a replay record. */ + public enum Rejection { + + /** The message names a type this deployment does not publish. */ + UNKNOWN_TYPE, + + /** The type exists but is not something the server sends. */ + WRONG_DIRECTION, + + /** + * The type exists at a different major version. + * + *

The one that only happens on a replay path. A retained message from before a breaking + * change is still in the log, and forwarding it hands a client a document of a shape its + * current code cannot read. + */ + SCHEMA_MISMATCH, + + /** The message is missing a field the platform needs to place it. */ + MALFORMED + } + + /** + * The outcome of mapping one broker message. + * + * @param record the record, when it mapped + * @param rejection why it did not, when it did not + */ + public record Mapped(Optional record, Optional rejection) { + + public Mapped { + Objects.requireNonNull(record, "record"); + Objects.requireNonNull(rejection, "rejection"); + if (record.isPresent() == rejection.isPresent()) { + throw new IllegalArgumentException("a mapping either produced a record or a rejection"); + } + } + + static Mapped of(ReplayRecord record) { + return new Mapped(Optional.of(record), Optional.empty()); + } + + static Mapped rejected(Rejection rejection) { + return new Mapped(Optional.empty(), Optional.of(rejection)); + } + } + + private final WebSocketMessageCatalog catalog; + + /** + * A mapper over the published catalog. + * + * @param catalog which types this deployment publishes + */ + public ReplayEventMapper(WebSocketMessageCatalog catalog) { + this.catalog = Objects.requireNonNull(catalog, "catalog"); + } + + /** + * Maps one broker message. + * + * @param headers the broker's headers, from which the platform reads only what it declared + * @param payload the encoded document + */ + public Mapped map(Map headers, String payload) { + Objects.requireNonNull(headers, "headers"); + if (payload == null) { + return Mapped.rejected(Rejection.MALFORMED); + } + String streamId = headers.get("ws-stream-id"); + String rawType = headers.get("ws-message-type"); + String rawSequence = headers.get("ws-sequence"); + String rawOccurredAt = headers.get("ws-occurred-at"); + if (streamId == null || rawType == null || rawSequence == null || rawOccurredAt == null) { + return Mapped.rejected(Rejection.MALFORMED); + } + + WebSocketMessageType type; + long sequence; + Instant occurredAt; + try { + type = new WebSocketMessageType(rawType); + sequence = Long.parseLong(rawSequence); + occurredAt = Instant.ofEpochMilli(Long.parseLong(rawOccurredAt)); + } catch (RuntimeException malformed) { + return Mapped.rejected(Rejection.MALFORMED); + } + + var descriptor = catalog.find(type); + if (descriptor.isEmpty()) { + // A retained message naming a type this deployment no longer publishes. Forwarding it would + // deliver a shape the client's current code was never written against. + return Mapped.rejected(Rejection.UNKNOWN_TYPE); + } + if (!descriptor.get().direction().acceptsFromServer()) { + return Mapped.rejected(Rejection.WRONG_DIRECTION); + } + if (descriptor.get().schemaVersion().major() != type.version()) { + return Mapped.rejected(Rejection.SCHEMA_MISMATCH); + } + + try { + return Mapped.of(new ReplayRecord(streamId, sequence, type, payload, occurredAt)); + } catch (IllegalArgumentException malformed) { + return Mapped.rejected(Rejection.MALFORMED); + } + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/cluster/ReplayRecord.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/cluster/ReplayRecord.java new file mode 100644 index 00000000..186681a2 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/cluster/ReplayRecord.java @@ -0,0 +1,37 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.cluster; + +import dev.caskeleton.adapter.inbound.websocket.protocol.WebSocketMessageType; +import java.time.Instant; +import java.util.Objects; + +/** + * One retained message, as the replay source holds it. + * + *

Deliberately not the broker's own message. A broker record carries headers, delivery counts, + * routing keys and whatever the producer put on it, and forwarding that to a client publishes the + * internal topology — the queue names, the retry counts, the fact that a message was redelivered + * eleven times. It also makes the client's contract whatever the broker happens to serialize, which + * changes when the broker is upgraded. + * + *

So a replay record is the platform's own shape, and mapping into it is where a broker message + * that does not fit gets rejected rather than passed through. + * + * @param streamId which stream it belongs to + * @param sequence its position on that stream + * @param type the published message type + * @param payload the encoded document + * @param occurredAt when it was produced + */ +public record ReplayRecord( + String streamId, long sequence, WebSocketMessageType type, String payload, Instant occurredAt) { + + public ReplayRecord { + Objects.requireNonNull(streamId, "streamId"); + Objects.requireNonNull(type, "type"); + Objects.requireNonNull(payload, "payload"); + Objects.requireNonNull(occurredAt, "occurredAt"); + if (sequence < 1) { + throw new IllegalArgumentException("stream positions start at 1, was " + sequence); + } + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/codec/BinaryCodecProfile.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/codec/BinaryCodecProfile.java new file mode 100644 index 00000000..eea71a86 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/codec/BinaryCodecProfile.java @@ -0,0 +1,44 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.codec; + +import dev.caskeleton.adapter.inbound.websocket.protocol.WebSocketSchemaVersion; +import java.util.Objects; + +/** + * What every Advanced binary codec must agree with the Stable JSON one about. + * + *

The reason this is a shared type rather than two independent profiles: a second codec is only + * safe if it means the same thing. If Protobuf and JSON disagree about which message type a frame + * is, or which schema version it was written against, then a client that switches format has + * silently switched contract — and the disagreement shows up as a handler receiving a well-formed + * message it was not expecting, which reads as an application bug. + * + * @param messageTypeCarried whether the frame names its message type, as JSON does + * @param schemaVersion the catalog version this codec is bound to + * @param maxMessageBytes the ceiling on one decoded message + * @param unknownFieldAllowed whether a field the reader does not know is tolerated + */ +public record BinaryCodecProfile( + boolean messageTypeCarried, + WebSocketSchemaVersion schemaVersion, + int maxMessageBytes, + boolean unknownFieldAllowed) { + + public BinaryCodecProfile { + Objects.requireNonNull(schemaVersion, "schemaVersion"); + if (!messageTypeCarried) { + throw new IllegalArgumentException( + "a binary frame must name its message type as the JSON one does; without it the reader " + + "guesses from context and a client that switched format has switched contract"); + } + if (maxMessageBytes < 1) { + throw new IllegalArgumentException( + "a binary codec without a size ceiling decodes whatever arrives, and a length-prefixed " + + "format will happily allocate the length it was told"); + } + } + + /** Whether a frame of this size may be decoded at all. */ + public boolean withinBounds(int frameBytes) { + return frameBytes >= 0 && frameBytes <= maxMessageBytes; + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/codec/SchemaParity.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/codec/SchemaParity.java new file mode 100644 index 00000000..ad22adb2 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/codec/SchemaParity.java @@ -0,0 +1,105 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.codec; + +import dev.caskeleton.adapter.inbound.websocket.protocol.WebSocketMessageType; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.TreeSet; + +/** + * Whether an Advanced codec publishes the same message catalog as the Stable JSON one. + * + *

This is the check that makes a second codec safe rather than merely present. If the binary + * codec knows a type JSON does not, a client that switches format gains a message the platform was + * never reviewed as accepting; if JSON knows one the binary codec does not, a client that switches + * loses a message and the loss appears as a handler that stopped being called. + * + *

Neither shows up in a codec's own round-trip test, which is why this compares catalogs rather + * than encodings. + */ +public final class SchemaParity { + + private SchemaParity() {} + + /** + * Differences between two catalogs. + * + * @param jsonTypes the Stable JSON catalog's published types + * @param binaryTypes the Advanced codec's published types + */ + public static List differences( + Set jsonTypes, Set binaryTypes) { + Objects.requireNonNull(jsonTypes, "jsonTypes"); + Objects.requireNonNull(binaryTypes, "binaryTypes"); + List differences = new ArrayList<>(); + Set onlyJson = new TreeSet<>(); + Set onlyBinary = new TreeSet<>(); + jsonTypes.stream() + .filter(type -> !binaryTypes.contains(type)) + .forEach(type -> onlyJson.add(type.value())); + binaryTypes.stream() + .filter(type -> !jsonTypes.contains(type)) + .forEach(type -> onlyBinary.add(type.value())); + if (!onlyJson.isEmpty()) { + differences.add( + "published as JSON but not by the binary codec, so a client that switches format loses " + + "them: " + + onlyJson); + } + if (!onlyBinary.isEmpty()) { + differences.add( + "published by the binary codec but not as JSON, so a client that switches format gains " + + "messages the platform was not reviewed as accepting: " + + onlyBinary); + } + return List.copyOf(differences); + } + + /** + * Fail when the catalogs disagree. + * + * @param jsonTypes the Stable JSON catalog's published types + * @param binaryTypes the Advanced codec's published types + */ + public static void verify( + Set jsonTypes, Set binaryTypes) { + List differences = differences(jsonTypes, binaryTypes); + if (!differences.isEmpty()) { + throw new IllegalStateException( + "the binary codec and the JSON codec do not publish the same catalog: " + differences); + } + } + + /** + * Differences in what each catalog binds a type to. + * + *

Same types is not enough: two codecs can agree that {@code order.placed.v1} exists and + * disagree about what it decodes to, which produces a handler receiving a well-formed object of + * the wrong shape. + * + * @param jsonBindings the Stable JSON bindings + * @param binaryBindings the Advanced codec's bindings + */ + public static List bindingDifferences( + Map> jsonBindings, + Map> binaryBindings) { + Objects.requireNonNull(jsonBindings, "jsonBindings"); + Objects.requireNonNull(binaryBindings, "binaryBindings"); + List differences = new ArrayList<>(); + for (Map.Entry> binding : jsonBindings.entrySet()) { + Class other = binaryBindings.get(binding.getKey()); + if (other != null && !other.equals(binding.getValue())) { + differences.add( + binding.getKey().value() + + " decodes to " + + binding.getValue().getName() + + " as JSON and " + + other.getName() + + " in binary"); + } + } + return List.copyOf(differences); + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/codec/WebSocketBinaryCodecBackend.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/codec/WebSocketBinaryCodecBackend.java new file mode 100644 index 00000000..89415fe0 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/codec/WebSocketBinaryCodecBackend.java @@ -0,0 +1,64 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.codec; + +/** + * Whether a binary codec's format library is actually on the runtime classpath. + * + *

Both backends are compile-only in this leaf. That is not a packaging preference: each one is + * load-bearing for Spring Boot's auto-configuration by its mere presence. The CBOR backend makes + * Boot register a {@code cborMapper} bean; protobuf-java makes Spring register a Protobuf message + * converter. A composition root that adopted this leaf would acquire both without enabling either + * capability, which is the opposite of what an off-by-default flag promises. + * + *

The cost of that choice is this class. Without it, a deployment that names the capability and + * forgets the jar gets a {@code NoClassDefFoundError} thrown from inside a mapper builder, naming a + * Jackson or protobuf class and not the decision that caused it. + */ +public enum WebSocketBinaryCodecBackend { + + /** Jackson's CBOR backend. */ + CBOR( + "tools.jackson.dataformat.cbor.CBORMapper", + "tools.jackson.dataformat:jackson-dataformat-cbor"), + + /** The protobuf runtime. */ + PROTOBUF("com.google.protobuf.DynamicMessage", "com.google.protobuf:protobuf-java"); + + private final String probeClass; + private final String coordinate; + + WebSocketBinaryCodecBackend(String probeClass, String coordinate) { + this.probeClass = probeClass; + this.coordinate = coordinate; + } + + /** The Gradle coordinate a deployment adds to enable this codec. */ + public String coordinate() { + return coordinate; + } + + /** Whether the backend is present. */ + public boolean available() { + try { + Class.forName(probeClass, false, WebSocketBinaryCodecBackend.class.getClassLoader()); + return true; + } catch (ClassNotFoundException absent) { + return false; + } + } + + /** + * Refuse with a sentence rather than a {@code NoClassDefFoundError} naming a library class. + * + * @throws IllegalStateException when the backend is absent + */ + public void require() { + if (!available()) { + throw new IllegalStateException( + "this codec's capability is enabled but " + + coordinate + + " is not on the runtime classpath; it is compile-only here so that its presence" + + " stays a deployment's decision — on the classpath it registers a mapper bean and a" + + " message converter for every deployment, enabled or not"); + } + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/codec/cbor/CborCodecProfile.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/codec/cbor/CborCodecProfile.java new file mode 100644 index 00000000..e1a7cd01 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/codec/cbor/CborCodecProfile.java @@ -0,0 +1,53 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.codec.cbor; + +import dev.caskeleton.adapter.inbound.websocket.advanced.codec.BinaryCodecProfile; +import java.util.Objects; + +/** + * CBOR as a wire codec. + * + *

Enabled only where a client population actually asks for it. CBOR buys compactness over JSON, + * and pays for it with a second decode path reachable from an unauthenticated frame — which is the + * most expensive surface in the platform to add to for a saving that a deployment can usually get + * from compression instead. + * + *

Two settings decide whether two systems reading the same bytes agree. Canonical encoding fixes + * how a value is written, so the same message has one representation and a signature over it means + * something. The duplicate-key policy fixes what an invalid map does — RFC 8949 leaves that to the + * decoder, and a validator and an executor that resolve it differently will disagree about what the + * message said, with the attacker choosing both readings. + * + * @param binary the bounds and semantics shared with every other codec + * @param canonicalEncodingRequired whether values must be in RFC 8949 canonical form + * @param duplicateKeyPolicy what an invalid map does + */ +public record CborCodecProfile( + BinaryCodecProfile binary, + boolean canonicalEncodingRequired, + DuplicateKeyPolicy duplicateKeyPolicy) { + + public CborCodecProfile { + Objects.requireNonNull(binary, "binary"); + Objects.requireNonNull(duplicateKeyPolicy, "duplicateKeyPolicy"); + } + + /** The recommended shape: canonical, and duplicate keys refused. */ + public static CborCodecProfile strict(BinaryCodecProfile binary) { + return new CborCodecProfile(binary, true, DuplicateKeyPolicy.REJECT); + } + + /** + * Whether a message decoded under this profile can be relied on to mean one thing. + * + *

False when duplicate keys are resolved rather than refused: the bytes then have two readings + * and which one a component sees depends on which decoder it uses. + */ + public boolean unambiguous() { + return duplicateKeyPolicy == DuplicateKeyPolicy.REJECT; + } + + /** Whether a decoded message is within bounds. */ + public boolean withinBounds(int messageBytes) { + return binary.withinBounds(messageBytes); + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/codec/cbor/DuplicateKeyPolicy.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/codec/cbor/DuplicateKeyPolicy.java new file mode 100644 index 00000000..39bf3a76 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/codec/cbor/DuplicateKeyPolicy.java @@ -0,0 +1,21 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.codec.cbor; + +/** + * What to do with a CBOR map that names the same key twice. + * + *

RFC 8949 calls such a map invalid but does not require a decoder to reject it, and decoders + * differ: some keep the first value, some the last. That difference is exploitable whenever two + * systems read the same bytes — a validator that reads the first value and an executor that reads + * the last will disagree about what the message said, and the attacker chooses both. + */ +public enum DuplicateKeyPolicy { + + /** Refuse the message. The only setting that makes two decoders agree. */ + REJECT, + + /** Keep the first occurrence. Recorded so a deployment that needs it says so. */ + FIRST_WINS, + + /** Keep the last occurrence. */ + LAST_WINS +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/codec/cbor/WebSocketCborCodec.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/codec/cbor/WebSocketCborCodec.java new file mode 100644 index 00000000..2261b4f2 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/codec/cbor/WebSocketCborCodec.java @@ -0,0 +1,166 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.codec.cbor; + +import dev.caskeleton.adapter.inbound.websocket.advanced.codec.BinaryCodecProfile; +import dev.caskeleton.adapter.inbound.websocket.advanced.codec.WebSocketBinaryCodecBackend; +import dev.caskeleton.adapter.inbound.websocket.codec.WebSocketDecodeException; +import dev.caskeleton.adapter.inbound.websocket.codec.WebSocketWireTypeManifest; +import dev.caskeleton.adapter.inbound.websocket.error.WebSocketFailureCategory; +import dev.caskeleton.adapter.inbound.websocket.protocol.WebSocketMessageCatalog; +import dev.caskeleton.adapter.inbound.websocket.protocol.WebSocketMessageDescriptor; +import dev.caskeleton.adapter.inbound.websocket.protocol.WebSocketMessageFamily; +import dev.caskeleton.adapter.inbound.websocket.protocol.WebSocketMessageType; +import java.util.Objects; +import tools.jackson.core.StreamReadConstraints; +import tools.jackson.core.StreamReadFeature; +import tools.jackson.databind.DeserializationFeature; +import tools.jackson.databind.MapperFeature; +import tools.jackson.dataformat.cbor.CBORFactory; +import tools.jackson.dataformat.cbor.CBORMapper; + +/** + * The CBOR wire codec, admitted through the same catalog as JSON and refused the same things. + * + *

The catalog is consulted before the parser, exactly as the JSON codec does it. That ordering + * is the security property: an unpublished type never reaches a decoder at all, so adding a second + * decode path does not add a second set of reachable types. + * + *

Duplicate map keys are refused rather than resolved. RFC 8949 calls such a map invalid and + * lets a decoder do as it likes, so two systems reading the same bytes can disagree about what the + * message said — a validator that keeps the first value and an executor that keeps the last, with + * the attacker choosing both readings. {@link CborCodecProfile#unambiguous()} states the same rule + * as a fact about the profile; this is where it is enforced. + * + *

Bounds are applied before parsing, not after. CBOR declares a collection's length ahead of its + * contents, so a frame well inside the transport limit can ask the decoder for an allocation far + * larger than the frame — a limit checked afterwards has already paid for it. + */ +public final class WebSocketCborCodec { + + private final CBORMapper mapper; + private final WebSocketMessageCatalog catalog; + private final WebSocketWireTypeManifest manifest; + private final CborCodecProfile profile; + + /** + * A codec over one catalog and manifest. + * + * @param catalog which types are published + * @param manifest which classes they decode to + * @param profile the bounds and the duplicate-key rule + */ + public WebSocketCborCodec( + WebSocketMessageCatalog catalog, + WebSocketWireTypeManifest manifest, + CborCodecProfile profile) { + // Before the mapper is built, so an absent backend is a sentence naming the missing coordinate + // rather than a NoClassDefFoundError thrown from inside CBORMapper.builder. + WebSocketBinaryCodecBackend.CBOR.require(); + this.catalog = Objects.requireNonNull(catalog, "catalog"); + this.manifest = Objects.requireNonNull(manifest, "manifest"); + this.profile = Objects.requireNonNull(profile, "profile"); + if (!profile.unambiguous()) { + // Refused at construction. A profile that resolves duplicate keys instead of rejecting them + // produces bytes with two readings, and which one a component sees depends on which decoder + // it happens to use. + throw new IllegalArgumentException( + "a CBOR codec whose duplicate-key policy is not REJECT decodes bytes that mean two" + + " different things to two readers"); + } + BinaryCodecProfile binary = profile.binary(); + this.mapper = + CBORMapper.builder( + CBORFactory.builder() + .streamReadConstraints( + StreamReadConstraints.builder() + .maxNestingDepth(32) + .maxDocumentLength(binary.maxMessageBytes()) + .maxStringLength(binary.maxMessageBytes()) + .maxNumberLength(64) + .build()) + .enable(StreamReadFeature.STRICT_DUPLICATE_DETECTION) + .build()) + .configure( + DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, !binary.unknownFieldAllowed()) + .configure(DeserializationFeature.FAIL_ON_TRAILING_TOKENS, true) + .configure(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES, true) + .configure(MapperFeature.ACCEPT_CASE_INSENSITIVE_PROPERTIES, false) + .configure(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS, false) + .disable(DeserializationFeature.ACCEPT_FLOAT_AS_INT) + .build(); + } + + /** The profile this codec runs under. */ + public CborCodecProfile profile() { + return profile; + } + + /** + * Decodes one inbound frame. + * + * @param type the published type the envelope named + * @param family the family the envelope claimed + * @param payload the encoded frame + * @throws WebSocketDecodeException when anything about it is not exactly right + */ + public Object decodeFromClient( + WebSocketMessageType type, WebSocketMessageFamily family, byte[] payload) { + Objects.requireNonNull(type, "type"); + Objects.requireNonNull(family, "family"); + Objects.requireNonNull(payload, "payload"); + if (!profile.withinBounds(payload.length)) { + throw new WebSocketDecodeException( + WebSocketFailureCategory.TOO_LARGE, "the frame exceeds the codec's ceiling"); + } + WebSocketMessageDescriptor descriptor = + catalog + .admitFromClient(type, family) + .orElseThrow( + () -> + new WebSocketDecodeException( + WebSocketFailureCategory.UNKNOWN_TYPE, + "this endpoint does not accept " + type + " from a client")); + Class target = + manifest + .targetFor(descriptor.type()) + .orElseThrow( + () -> + new WebSocketDecodeException( + WebSocketFailureCategory.INTERNAL, + "the catalog publishes " + + type + + " but the manifest binds no class to it")); + try { + return mapper.readValue(payload, target); + } catch (RuntimeException malformed) { + // The parser's message names the class, the field and often the offending bytes. None of it + // goes to the peer; the category is what a client can act on. + throw new WebSocketDecodeException( + WebSocketFailureCategory.MALFORMED, "the payload is not a valid " + type + " document"); + } + } + + /** + * Encodes one outbound frame. + * + * @param type the published type + * @param value the document to send + */ + public byte[] encodeToClient(WebSocketMessageType type, Object value) { + Objects.requireNonNull(type, "type"); + Objects.requireNonNull(value, "value"); + if (!catalog.acceptsFromServer(type)) { + throw new WebSocketDecodeException( + WebSocketFailureCategory.INTERNAL, + "the catalog does not publish " + type + " from the server"); + } + byte[] encoded = mapper.writeValueAsBytes(value); + if (!profile.withinBounds(encoded.length)) { + // Checked on the way out too. An oversized outbound frame is a server defect rather than a + // client one, and discovering it at the socket means the frame is already half written. + throw new WebSocketDecodeException( + WebSocketFailureCategory.TOO_LARGE, + "the encoded " + type + " exceeds the codec's ceiling"); + } + return encoded; + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/codec/protobuf/DescriptorCompatibility.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/codec/protobuf/DescriptorCompatibility.java new file mode 100644 index 00000000..031df9b3 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/codec/protobuf/DescriptorCompatibility.java @@ -0,0 +1,29 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.codec.protobuf; + +/** + * How a descriptor change relates to the descriptors already deployed. + * + *

Protobuf's wire format is forgiving in a way that hides the dangerous changes. Renaming a + * field is invisible on the wire and therefore safe; changing a field's number, or reusing a + * retired one, silently reinterprets old bytes as a different field. Nothing errors — the receiver + * reads a value of the right type in the wrong place. + */ +public enum DescriptorCompatibility { + + /** Old readers read new messages and new readers read old ones. */ + FULLY_COMPATIBLE, + + /** New readers read old messages. Safe when every writer is upgraded first. */ + BACKWARD_COMPATIBLE, + + /** Old readers read new messages. Safe when every reader is upgraded first. */ + FORWARD_COMPATIBLE, + + /** + * Old bytes decode to something different. + * + *

A changed or reused field number, or a changed wire type on an existing number. The receiver + * does not error; it reads the wrong field, which surfaces as a data bug somewhere far away. + */ + BREAKING +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/codec/protobuf/DescriptorCompatibilityGate.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/codec/protobuf/DescriptorCompatibilityGate.java new file mode 100644 index 00000000..57156efe --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/codec/protobuf/DescriptorCompatibilityGate.java @@ -0,0 +1,79 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.codec.protobuf; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * Refuses a descriptor change that would reinterpret bytes already on the wire. + * + *

Required rather than advisory. During any rolling deploy both descriptor versions are live at + * once, so a breaking change is not a future problem — it is a guaranteed present one for the + * duration of the rollout, and it produces wrong values rather than errors. + * + *

The gate works on field numbers and wire types, because those are what the format actually + * carries. Field names are absent from the encoding entirely, which is why renaming is free and why + * a gate that compared names would pass exactly the changes that matter. + */ +public final class DescriptorCompatibilityGate { + + private DescriptorCompatibilityGate() {} + + /** + * Compare two descriptor field maps. + * + * @param deployed field number to wire type, as currently deployed + * @param candidate field number to wire type, as proposed + */ + public static DescriptorCompatibility compare( + Map deployed, Map candidate) { + Objects.requireNonNull(deployed, "deployed"); + Objects.requireNonNull(candidate, "candidate"); + for (Map.Entry field : deployed.entrySet()) { + String proposed = candidate.get(field.getKey()); + if (proposed != null && !proposed.equals(field.getValue())) { + // Same number, different type. Old bytes decode as the new type without complaint. + return DescriptorCompatibility.BREAKING; + } + } + boolean removed = deployed.keySet().stream().anyMatch(number -> !candidate.containsKey(number)); + boolean added = candidate.keySet().stream().anyMatch(number -> !deployed.containsKey(number)); + if (removed && added) { + return DescriptorCompatibility.BREAKING; + } + if (added) { + return DescriptorCompatibility.BACKWARD_COMPATIBLE; + } + if (removed) { + return DescriptorCompatibility.FORWARD_COMPATIBLE; + } + return DescriptorCompatibility.FULLY_COMPATIBLE; + } + + /** + * Fail the build for a change that cannot be rolled out. + * + * @param deployed field number to wire type, as currently deployed + * @param candidate field number to wire type, as proposed + */ + public static void verify(Map deployed, Map candidate) { + DescriptorCompatibility verdict = compare(deployed, candidate); + if (verdict == DescriptorCompatibility.BREAKING) { + List details = new ArrayList<>(); + for (Map.Entry field : deployed.entrySet()) { + String proposed = candidate.get(field.getKey()); + if (proposed != null && !proposed.equals(field.getValue())) { + details.add("field " + field.getKey() + ": " + field.getValue() + " became " + proposed); + } + } + deployed.keySet().stream() + .filter(number -> !candidate.containsKey(number)) + .forEach(number -> details.add("field " + number + " removed")); + throw new IllegalStateException( + "the descriptor change reinterprets bytes already on the wire; during a rolling deploy " + + "both versions are live and the receiver reads the wrong field without erroring: " + + details); + } + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/codec/protobuf/ProtobufCodecProfile.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/codec/protobuf/ProtobufCodecProfile.java new file mode 100644 index 00000000..1ecc4856 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/codec/protobuf/ProtobufCodecProfile.java @@ -0,0 +1,51 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.codec.protobuf; + +import dev.caskeleton.adapter.inbound.websocket.advanced.codec.BinaryCodecProfile; +import java.util.Objects; + +/** + * Protobuf as a wire codec, and the descriptors it is bound to. + * + *

The artifact is named because a Protobuf codec is only as compatible as the descriptor set it + * was generated from, and "the current one" is not a version anybody can compare against during an + * incident. + * + *

{@code maxMessageBytes} bounds the decoded message, and it is separate from the frame bound + * for the reason length-prefixed formats always need it: the frame carries a length, the decoder + * allocates it, and a frame within the transport limit can still declare a length far beyond it. + * + *

File bytes do not go in a Protobuf payload. A binary field is exactly as convenient for an + * attachment as it looks, and the result is a message the broker, the replay store and every + * in-memory queue must hold whole. The reference belongs on the wire and the bytes belong in object + * storage. + * + * @param descriptorArtifact the coordinates of the descriptor set this codec was generated from + * @param binary the bounds and semantics shared with every other codec + */ +public record ProtobufCodecProfile(String descriptorArtifact, BinaryCodecProfile binary) { + + /** Above this, a payload is an attachment pretending to be a message. */ + public static final int ATTACHMENT_THRESHOLD_BYTES = 256 * 1024; + + public ProtobufCodecProfile { + Objects.requireNonNull(descriptorArtifact, "descriptorArtifact"); + Objects.requireNonNull(binary, "binary"); + if (descriptorArtifact.isBlank()) { + throw new IllegalArgumentException( + "the descriptor artifact must be named; 'the current one' is not a version that can be " + + "compared against during an incident"); + } + if (binary.maxMessageBytes() > ATTACHMENT_THRESHOLD_BYTES) { + throw new IllegalArgumentException( + "a message ceiling above " + + ATTACHMENT_THRESHOLD_BYTES + + " bytes makes the codec an attachment transport; the broker, the replay store and " + + "every in-memory queue would each hold it whole"); + } + } + + /** Whether a decoded message is within bounds. */ + public boolean withinBounds(int messageBytes) { + return binary.withinBounds(messageBytes); + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/codec/protobuf/WebSocketProtobufCodec.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/codec/protobuf/WebSocketProtobufCodec.java new file mode 100644 index 00000000..3b976197 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/codec/protobuf/WebSocketProtobufCodec.java @@ -0,0 +1,159 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.codec.protobuf; + +import com.google.protobuf.CodedInputStream; +import com.google.protobuf.Descriptors; +import com.google.protobuf.DynamicMessage; +import com.google.protobuf.Message; +import dev.caskeleton.adapter.inbound.websocket.advanced.codec.WebSocketBinaryCodecBackend; +import dev.caskeleton.adapter.inbound.websocket.codec.WebSocketDecodeException; +import dev.caskeleton.adapter.inbound.websocket.error.WebSocketFailureCategory; +import dev.caskeleton.adapter.inbound.websocket.protocol.WebSocketMessageCatalog; +import dev.caskeleton.adapter.inbound.websocket.protocol.WebSocketMessageFamily; +import dev.caskeleton.adapter.inbound.websocket.protocol.WebSocketMessageType; +import java.util.Map; +import java.util.Objects; + +/** + * The Protobuf wire codec, driven by descriptors rather than generated classes. + * + *

Descriptor-driven because a template has no {@code .proto} of its own and should not invent + * one. A deployment compiles its schema, supplies the resulting {@link Descriptors.Descriptor} per + * published message type, and this decodes into {@link DynamicMessage} — which is what protobuf's + * own tooling does when the schema is not known at build time. The alternative, generating a + * placeholder message so the codec has something to compile against, would ship a wire contract + * nobody chose. + * + *

Unknown fields are dropped rather than preserved. Protobuf's default is to + * retain them so an intermediary can round-trip a message it does not fully understand, and that is + * exactly wrong at a trust boundary: retaining means this server would re-emit bytes it never + * validated, and a field it cannot see is a field its authorization checks cannot consider. + * + *

The recursion limit is set below protobuf's default. Nested length-delimited + * fields are parsed recursively, so a small frame can drive deep recursion; the default of 100 is + * generous for a message that is supposed to be a transport envelope. + * + *

The catalog is consulted before the parser, as in every other codec here. An unpublished type + * never reaches a decoder, so a second decode path does not widen the set of reachable types. + */ +public final class WebSocketProtobufCodec { + + /** Deeper than any envelope and far below protobuf's default of 100. */ + private static final int MAX_RECURSION_DEPTH = 16; + + private final WebSocketMessageCatalog catalog; + private final Map descriptors; + private final ProtobufCodecProfile profile; + + /** + * A codec over one catalog and one descriptor set. + * + * @param catalog which types are published + * @param descriptors the compiled descriptor per published type + * @param profile the descriptor artifact this was built from, and the bounds + */ + public WebSocketProtobufCodec( + WebSocketMessageCatalog catalog, + Map descriptors, + ProtobufCodecProfile profile) { + WebSocketBinaryCodecBackend.PROTOBUF.require(); + this.catalog = Objects.requireNonNull(catalog, "catalog"); + this.descriptors = Map.copyOf(Objects.requireNonNull(descriptors, "descriptors")); + this.profile = Objects.requireNonNull(profile, "profile"); + if (this.descriptors.isEmpty()) { + throw new IllegalArgumentException( + "a Protobuf codec with no descriptor decodes nothing; a deployment that has not compiled" + + " its schema should not enable the capability"); + } + } + + /** The profile, including the descriptor artifact this was built from. */ + public ProtobufCodecProfile profile() { + return profile; + } + + /** + * Decodes one inbound frame. + * + * @param type the published type the envelope named + * @param family the family the envelope claimed + * @param payload the encoded frame + * @throws WebSocketDecodeException when anything about it is not exactly right + */ + public Message decodeFromClient( + WebSocketMessageType type, WebSocketMessageFamily family, byte[] payload) { + Objects.requireNonNull(type, "type"); + Objects.requireNonNull(family, "family"); + Objects.requireNonNull(payload, "payload"); + if (!profile.withinBounds(payload.length)) { + throw new WebSocketDecodeException( + WebSocketFailureCategory.TOO_LARGE, "the frame exceeds the codec's ceiling"); + } + if (catalog.admitFromClient(type, family).isEmpty()) { + throw new WebSocketDecodeException( + WebSocketFailureCategory.UNKNOWN_TYPE, + "this endpoint does not accept " + type + " from a client"); + } + Descriptors.Descriptor descriptor = descriptors.get(type); + if (descriptor == null) { + throw new WebSocketDecodeException( + WebSocketFailureCategory.INTERNAL, + "the catalog publishes " + type + " but no descriptor is compiled for it"); + } + try { + CodedInputStream input = CodedInputStream.newInstance(payload); + input.setRecursionLimit(MAX_RECURSION_DEPTH); + DynamicMessage.Builder builder = DynamicMessage.newBuilder(descriptor); + builder.mergeFrom(input); + // Checked explicitly. mergeFrom does not fail on a truncated frame that happens to end on a + // field boundary, and a message missing a required field would otherwise reach a handler + // that has no way to tell it apart from one where the field was legitimately absent. + input.checkLastTagWas(0); + DynamicMessage message = builder.build(); + if (!message.getUnknownFields().asMap().isEmpty()) { + // Not merely dropped — refused. A frame carrying fields this build does not know is either + // a newer peer or a probe, and both deserve an explicit answer rather than silent partial + // acceptance. + throw new WebSocketDecodeException( + WebSocketFailureCategory.MALFORMED, + "the payload carries fields this build does not publish for " + type); + } + return message; + } catch (java.io.IOException malformed) { + // The parser's message names field numbers and offsets. None of it goes to the peer. + throw new WebSocketDecodeException( + WebSocketFailureCategory.MALFORMED, "the payload is not a valid " + type + " message"); + } + } + + /** + * Encodes one outbound frame. + * + * @param type the published type + * @param message the message to send, built against this type's descriptor + */ + public byte[] encodeToClient(WebSocketMessageType type, Message message) { + Objects.requireNonNull(type, "type"); + Objects.requireNonNull(message, "message"); + if (!catalog.acceptsFromServer(type)) { + throw new WebSocketDecodeException( + WebSocketFailureCategory.INTERNAL, + "the catalog does not publish " + type + " from the server"); + } + Descriptors.Descriptor descriptor = descriptors.get(type); + if (descriptor == null || !descriptor.equals(message.getDescriptorForType())) { + // A message built against a different descriptor encodes to bytes the peer will decode as + // this type — field numbers collide across schemas, so the result is not a parse failure but + // a well-formed message with the wrong values. + throw new WebSocketDecodeException( + WebSocketFailureCategory.INTERNAL, + "the message was built against a different descriptor than " + type + " publishes"); + } + byte[] encoded = message.toByteArray(); + if (!profile.withinBounds(encoded.length)) { + throw new WebSocketDecodeException( + WebSocketFailureCategory.TOO_LARGE, + "the encoded " + type + " exceeds the codec's ceiling"); + } + return encoded; + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/compression/CompressionPolicy.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/compression/CompressionPolicy.java new file mode 100644 index 00000000..53e7902a --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/compression/CompressionPolicy.java @@ -0,0 +1,91 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.compression; + +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketEndpointName; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +/** + * Whether one endpoint may compress, and how large a decompressed message may get. + * + *

Two bounds, and the second is the one that is usually missing. The frame size limit applies to + * what arrives on the wire; a compressed frame within that limit can inflate to hundreds of times + * its size, so a 64KB frame limit with a 1000:1 ratio is a 64MB allocation from a single frame that + * every inbound bound was satisfied with. The decompressed limit has to be checked during + * inflation, not after, or the allocation has already happened by the time it fails. + */ +public final class CompressionPolicy { + + private final Map contentClasses; + private final CompressionProfile profile; + private final long maxDecompressedBytes; + + /** + * @param profile the negotiated parameters + * @param maxDecompressedBytes the ceiling on one inflated message + * @param contentClasses what each endpoint carries + */ + public CompressionPolicy( + CompressionProfile profile, + long maxDecompressedBytes, + Map contentClasses) { + this.profile = Objects.requireNonNull(profile, "profile"); + this.contentClasses = Map.copyOf(Objects.requireNonNull(contentClasses, "contentClasses")); + if (profile.enabled() && maxDecompressedBytes < 1) { + throw new IllegalArgumentException( + "compression without a decompressed-size ceiling turns one frame inside the inbound " + + "limit into an allocation hundreds of times larger"); + } + this.maxDecompressedBytes = maxDecompressedBytes; + } + + /** The negotiated parameters. */ + public CompressionProfile profile() { + return profile; + } + + /** The ceiling on one inflated message. */ + public long maxDecompressedBytes() { + return maxDecompressedBytes; + } + + /** + * Whether this endpoint may negotiate the extension. + * + *

Refused for an endpoint that mixes a secret with attacker-influenced content, regardless of + * how the profile is configured. No parameter combination makes that combination safe: the leak + * is in the compressed length, which every setting produces. + */ + public boolean mayCompress(WebSocketEndpointName endpoint) { + if (!profile.enabled()) { + return false; + } + return contentClassOf(endpoint) + .map(kind -> kind != EndpointContentClass.SENSITIVE_WITH_ATTACKER_INFLUENCE) + // An undeclared endpoint does not compress. Defaulting the other way makes every endpoint + // somebody forgot to classify a candidate for the one failure this class prevents. + .orElse(false); + } + + /** What the endpoint was declared to carry. */ + public Optional contentClassOf(WebSocketEndpointName endpoint) { + return Optional.ofNullable(contentClasses.get(endpoint)); + } + + /** + * Whether inflation may continue. + * + * @param bytesSoFar how much has been produced already + */ + public boolean mayInflateFurther(long bytesSoFar) { + return bytesSoFar <= maxDecompressedBytes; + } + + /** The expansion ratio a frame of this size is allowed before the ceiling stops it. */ + public long permittedRatioFor(long compressedBytes) { + if (compressedBytes <= 0) { + throw new IllegalArgumentException("a frame has a positive size"); + } + return maxDecompressedBytes / compressedBytes; + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/compression/CompressionProfile.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/compression/CompressionProfile.java new file mode 100644 index 00000000..ab594a1e --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/compression/CompressionProfile.java @@ -0,0 +1,72 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.compression; + +/** + * RFC 7692 {@code permessage-deflate} parameters for one endpoint. + * + *

Off by default and per endpoint, never global. Compression on a connection that carries + * attacker-influenced data alongside a secret is the CRIME/BREACH shape: the compressed length of a + * message leaks how much the attacker's guess overlapped the secret, one byte at a time. Deciding + * that requires knowing what the endpoint carries, which is an endpoint-level fact. + * + *

The two {@code noContextTakeover} flags are the memory bound. With context takeover, each + * connection keeps a sliding window alive between messages — a 32KB window per direction per + * connection, so ten thousand connections is 640MB of window before a single message is buffered. + * Turning it off costs compression ratio and makes the memory cost per connection a constant rather + * than a function of how long it stays open. + * + * @param enabled whether the extension is offered at all + * @param serverNoContextTakeover whether the server resets its window between messages + * @param clientNoContextTakeover whether the client is required to reset its window + * @param maxWindowBits the window size exponent, 8 to 15 per the RFC + * @param minCompressBytes the size below which a message is sent uncompressed + */ +public record CompressionProfile( + boolean enabled, + boolean serverNoContextTakeover, + boolean clientNoContextTakeover, + int maxWindowBits, + int minCompressBytes) { + + public CompressionProfile { + if (enabled) { + if (maxWindowBits < 8 || maxWindowBits > 15) { + throw new IllegalArgumentException( + "permessage-deflate window bits must be 8..15 per RFC 7692 section 7.1.2: " + + maxWindowBits); + } + if (minCompressBytes < 0) { + throw new IllegalArgumentException("a negative compression threshold is not a size"); + } + } + } + + /** The default: nothing compressed. */ + public static CompressionProfile disabled() { + return new CompressionProfile(false, true, true, 15, 0); + } + + /** + * Compression with both windows reset between messages. + * + *

The only shape recommended for a connection whose message count is not known in advance, + * because it is the only one whose memory cost does not grow with connection lifetime. + */ + public static CompressionProfile boundedMemory(int maxWindowBits, int minCompressBytes) { + return new CompressionProfile(true, true, true, maxWindowBits, minCompressBytes); + } + + /** Bytes of sliding window one connection holds, both directions. */ + public long windowBytesPerConnection() { + if (!enabled) { + return 0L; + } + long window = 1L << maxWindowBits; + long directions = (serverNoContextTakeover ? 0 : 1) + (long) (clientNoContextTakeover ? 0 : 1); + return window * directions; + } + + /** Whether a message of this size is worth compressing. */ + public boolean shouldCompress(int messageBytes) { + return enabled && messageBytes >= minCompressBytes; + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/compression/EndpointContentClass.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/compression/EndpointContentClass.java new file mode 100644 index 00000000..69c730f3 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/compression/EndpointContentClass.java @@ -0,0 +1,30 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.compression; + +/** + * What kind of data an endpoint's messages carry. + * + *

Declared per endpoint because it is the input to the one compression decision that cannot be + * made from configuration alone. The combination that matters is a secret and attacker-influenced + * text in the same compression context. + */ +public enum EndpointContentClass { + + /** Nothing sensitive and nothing the caller controls. Safe to compress. */ + PUBLIC_DATA, + + /** Content a caller can influence, with no secret alongside it. */ + ATTACKER_INFLUENCED, + + /** + * A secret — a token, a key, another user's data — with nothing attacker-controlled beside it. + */ + SENSITIVE, + + /** + * Both, in the same messages. + * + *

The CRIME/BREACH shape. Compressed length reveals how much a guess overlapped the secret, + * and an attacker who can inject a guess into each message recovers the secret a byte at a time. + */ + SENSITIVE_WITH_ATTACKER_INFLUENCE +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/graphql/GraphQlCloseCode.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/graphql/GraphQlCloseCode.java new file mode 100644 index 00000000..fbeb4871 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/graphql/GraphQlCloseCode.java @@ -0,0 +1,52 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.graphql; + +/** + * The close codes {@code graphql-transport-ws} defines for the transport layer. + * + *

Here rather than in the platform's {@code WebSocketCloseCode}, because these belong to one + * subprotocol. Two of them collide numerically with codes the platform already uses for unrelated + * reasons — 4401 is the platform's expired credential and 4408 its idle timeout — and merging the + * two vocabularies would make a close code ambiguous to whoever reads it in a log. + * + *

The specification assigns each of these a meaning the client acts on, which is why they are + * codes rather than a generic policy violation: a client that receives 4409 knows to pick a + * different operation id, and one that receives 4408 knows to send {@code connection_init} sooner. + */ +public enum GraphQlCloseCode { + + /** The connection was used before it was acknowledged. */ + UNAUTHORIZED(4401, "unauthorized"), + + /** {@code connection_init} did not arrive within the deadline. */ + INITIALISATION_TIMEOUT(4408, "connection initialisation timeout"), + + /** A live subscription already has this operation id. */ + SUBSCRIBER_ALREADY_EXISTS(4409, "subscriber already exists"), + + /** {@code connection_init} arrived more than once. */ + TOO_MANY_INITIALISATION_REQUESTS(4429, "too many initialisation requests"); + + private final int code; + private final String reason; + + GraphQlCloseCode(int code, String reason) { + this.code = code; + this.reason = reason; + } + + /** The numeric close code. */ + public int code() { + return code; + } + + /** + * The close reason. + * + *

Fixed text, never the client's own input. A close reason is echoed straight back over the + * wire, and reflecting what the client sent is how a close frame becomes an injection point into + * whatever reads the peer's logs. + */ + public String reason() { + return reason; + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/graphql/GraphQlTransportBridge.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/graphql/GraphQlTransportBridge.java new file mode 100644 index 00000000..0c6e8e8d --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/graphql/GraphQlTransportBridge.java @@ -0,0 +1,163 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.graphql; + +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketConnectionContext; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.BiConsumer; + +/** + * Carries {@code graphql-transport-ws} frames on the Stable connection runtime. + * + *

A transport bridge and nothing more. It moves opaque documents in both directions and enforces + * the two rules the subprotocol places on the transport itself — the ones a GraphQL engine cannot + * enforce because they are about the connection, not about a query. + * + *

{@code connection_init} must arrive first, once, within a deadline. The + * specification requires the server to close 4408 if it does not arrive in time and 4429 if it + * arrives twice. Both matter for the same reason: without them an unauthenticated socket can be + * held open indefinitely, which is a connection slot spent by anyone who can open a TCP connection. + * + *

A subscription id must be unique per connection. Reusing a live id is 4409, + * and the reason is not tidiness: the id is what routes {@code next} and {@code complete} frames + * back, so two subscriptions sharing one would interleave their results into a single client-side + * stream with nothing to separate them. + * + *

Everything else — what the document means, whether it validates, what an error looks like — is + * forwarded untouched. Reimplementing any of it would create a second definition of a GraphQL error + * that disagrees with the engine's the first time a resolver throws something unanticipated. + */ +public final class GraphQlTransportBridge { + + private final GraphQlTransportBridgePolicy policy; + private final Duration initDeadline; + private final Set liveSubscriptions = ConcurrentHashMap.newKeySet(); + private final AtomicReference acknowledgedAt = new AtomicReference<>(); + + /** + * @param policy the bridge's contract, which fixes what it does not own + * @param initDeadline how long a connection may stay unacknowledged + */ + public GraphQlTransportBridge(GraphQlTransportBridgePolicy policy, Duration initDeadline) { + this.policy = Objects.requireNonNull(policy, "policy"); + this.initDeadline = Objects.requireNonNull(initDeadline, "initDeadline"); + if (initDeadline.isNegative() || initDeadline.isZero()) { + throw new IllegalArgumentException( + "without an init deadline an unauthenticated socket is held for as long as the peer" + + " cares to hold it, which is a connection slot spent by anyone who can connect"); + } + } + + /** The contract this bridge runs under. */ + public GraphQlTransportBridgePolicy policy() { + return policy; + } + + /** + * Handle {@code connection_init}. + * + * @param at when it arrived + * @return empty when the connection is acknowledged, or the close code it earned + */ + public Optional onConnectionInit(Instant at) { + Objects.requireNonNull(at, "at"); + if (!acknowledgedAt.compareAndSet(null, at)) { + // 4429. A second init is not a retry: the first one already established the connection's + // parameters, and honouring a second would let a client change them mid-connection. + return Optional.of(GraphQlCloseCode.TOO_MANY_INITIALISATION_REQUESTS); + } + return Optional.empty(); + } + + /** + * Whether an unacknowledged connection has run out of time. + * + * @param now the current instant + * @param openedAt when the socket was accepted + */ + public Optional initTimeoutAt(Instant openedAt, Instant now) { + Objects.requireNonNull(openedAt, "openedAt"); + Objects.requireNonNull(now, "now"); + if (acknowledgedAt.get() != null) { + return Optional.empty(); + } + if (now.isBefore(openedAt.plus(initDeadline))) { + return Optional.empty(); + } + return Optional.of(GraphQlCloseCode.INITIALISATION_TIMEOUT); + } + + /** Whether the connection has been acknowledged. */ + public boolean acknowledged() { + return acknowledgedAt.get() != null; + } + + /** + * Handle {@code subscribe}. + * + * @param id the client-chosen operation id + * @return empty when the subscription may start, or the close code it earned + */ + public Optional onSubscribe(String id) { + Objects.requireNonNull(id, "id"); + if (!acknowledged()) { + // 4401. A subscribe before init is not merely early: nothing has authenticated the + // connection yet, so serving it would run an operation for a peer nobody identified. + return Optional.of(GraphQlCloseCode.UNAUTHORIZED); + } + if (!liveSubscriptions.add(id)) { + // 4409. The id routes next and complete frames back, so two live subscriptions sharing one + // interleave into a single client-side stream with nothing to separate them. + return Optional.of(GraphQlCloseCode.SUBSCRIBER_ALREADY_EXISTS); + } + return Optional.empty(); + } + + /** + * Handle {@code complete} from either side. + * + *

Releases the id for reuse, which the specification permits once the subscription has ended. + * Holding it would make a client that reuses ids sequentially fail on its second operation. + */ + public void onComplete(String id) { + liveSubscriptions.remove(Objects.requireNonNull(id, "id")); + } + + /** How many subscriptions are live on this connection. */ + public int liveSubscriptions() { + return liveSubscriptions.size(); + } + + /** + * Forward a frame to the GraphQL engine without interpreting it. + * + * @param context the Stable connection this frame arrived on + * @param payload the frame, exactly as received + * @param engine what to hand it to + */ + public void forward( + WebSocketConnectionContext context, + String payload, + BiConsumer engine) { + Objects.requireNonNull(context, "context"); + Objects.requireNonNull(payload, "payload"); + Objects.requireNonNull(engine, "engine"); + // Untouched. Parsing here to "validate" would be the start of a second GraphQL implementation, + // and it would disagree with the engine's the first time a resolver throws something this + // class did not anticipate. + engine.accept(context, payload); + } + + /** What this bridge enforces, as opposed to what it forwards. */ + public List enforcedByTheTransport() { + return List.of( + "connection_init arrives once, before anything else, within the deadline (4408, 4429)", + "subscribe arrives only after acknowledgement (4401)", + "an operation id is unique among live subscriptions (4409)"); + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/graphql/GraphQlTransportBridgePolicy.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/graphql/GraphQlTransportBridgePolicy.java new file mode 100644 index 00000000..57b723ee --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/graphql/GraphQlTransportBridgePolicy.java @@ -0,0 +1,71 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.graphql; + +import java.util.List; +import java.util.Objects; + +/** + * Carrying {@code graphql-transport-ws} frames on the Stable connection runtime. + * + *

A transport bridge and nothing else. The two false flags are the contract: this does not own + * GraphQL semantics, and it does not implement its own connection runtime. + * + *

The first matters because {@code graphql-transport-ws} has its own lifecycle — {@code + * connection_init}, {@code subscribe}, {@code next}, {@code complete}, {@code error} — that looks + * enough like a message protocol to reimplement by accident. Reimplementing it produces a second + * definition of what a GraphQL error is, and the two disagree the first time a resolver throws + * something the bridge did not anticipate. The GraphQL platform owns those semantics; this carries + * bytes. + * + *

The second matters because a bridge that opened its own connections would have its own + * heartbeat, its own queue bound, its own backpressure and its own security checks — none of which + * would be the ones the platform was reviewed with. + * + * @param subprotocol the negotiated subprotocol token + * @param ownsGraphQlSemantics always false + * @param usesStableConnectionRuntime always true + */ +public record GraphQlTransportBridgePolicy( + String subprotocol, boolean ownsGraphQlSemantics, boolean usesStableConnectionRuntime) { + + /** The subprotocol token defined by the graphql-ws specification. */ + public static final String SUBPROTOCOL = "graphql-transport-ws"; + + public GraphQlTransportBridgePolicy { + Objects.requireNonNull(subprotocol, "subprotocol"); + if (ownsGraphQlSemantics) { + throw new IllegalArgumentException( + "the bridge must not own GraphQL semantics: a second definition of what an error or a " + + "completed subscription is disagrees with the first the moment a resolver throws " + + "something it did not anticipate"); + } + if (!usesStableConnectionRuntime) { + throw new IllegalArgumentException( + "the bridge must reuse the Stable connection runtime: its own would have its own " + + "heartbeat, queue bound, backpressure and security checks, none of them the ones " + + "the platform was reviewed with"); + } + } + + /** The only shape this policy has. */ + public static GraphQlTransportBridgePolicy standard() { + return new GraphQlTransportBridgePolicy(SUBPROTOCOL, false, true); + } + + /** What the bridge forwards rather than interprets. */ + public List forwardedWithoutInterpretation() { + return List.of( + "connection_init and connection_ack", + "subscribe, next, complete and error", + "ping and pong at the graphql-ws layer", + "the GraphQL document, its variables and its extensions"); + } + + /** What it reuses from Stable rather than reimplementing. */ + public List reusedFromStable() { + return List.of( + "handshake admission: origin, authentication and connection budget", + "the outbound queue and its shedding bound", + "the heartbeat and the close orchestration", + "the connection evidence chain"); + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/http2/Http2CompatibilityProfile.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/http2/Http2CompatibilityProfile.java new file mode 100644 index 00000000..29cd4cfc --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/http2/Http2CompatibilityProfile.java @@ -0,0 +1,64 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.http2; + +import java.util.List; +import java.util.Objects; +import java.util.Set; + +/** + * WebSocket over HTTP/2 via RFC 8441 extended CONNECT. + * + *

The distinction this class exists to hold: the standard existing is not the same as the path + * supporting it. RFC 8441 has been published since 2018, and a request still fails if any single + * hop between the browser and the runtime does not implement extended CONNECT — a load balancer, an + * ingress controller, a corporate proxy. The failure is not a clean negotiation fallback; the + * intermediary sees a CONNECT it does not recognise and the connection dies. + * + *

So the profile is a list of hops somebody actually tested, not a boolean. Enabling it on the + * strength of "HTTP/2 is supported" is how a deployment discovers that its ingress is the one hop + * that is not, at the point where every client has already switched. + * + * @param enabled whether extended CONNECT is offered + * @param validatedClients client stacks an end-to-end test has passed against + * @param validatedProxies intermediaries an end-to-end test has passed through + */ +public record Http2CompatibilityProfile( + boolean enabled, Set validatedClients, Set validatedProxies) { + + public Http2CompatibilityProfile { + validatedClients = Set.copyOf(Objects.requireNonNull(validatedClients, "validatedClients")); + validatedProxies = Set.copyOf(Objects.requireNonNull(validatedProxies, "validatedProxies")); + if (enabled && (validatedClients.isEmpty() || validatedProxies.isEmpty())) { + throw new IllegalArgumentException( + "extended CONNECT cannot be enabled without a validated client and a validated proxy; " + + "one unsupporting hop kills the connection rather than negotiating a fallback"); + } + } + + /** The default. */ + public static Http2CompatibilityProfile disabled() { + return new Http2CompatibilityProfile(false, Set.of(), Set.of()); + } + + /** + * Whether a request arriving over this path may use extended CONNECT. + * + *

Both hops checked, and an unlisted hop is refused rather than assumed. The assumption is the + * whole failure: an untested intermediary is exactly the one that will not implement it. + */ + public boolean supports(String client, String proxy) { + return enabled && validatedClients.contains(client) && validatedProxies.contains(proxy); + } + + /** + * What a client that cannot use extended CONNECT does instead. + * + *

A classic HTTP/1.1 Upgrade, which every hop already handles. Keeping the fallback working is + * not optional: it is what every unlisted client uses, and it is what the listed ones fall back + * to when a proxy is swapped. + */ + public List fallbackPath() { + return List.of( + "HTTP/1.1 GET with Connection: Upgrade and Upgrade: websocket", + "the classic handshake must keep passing its own contract test while this is enabled"); + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/http3/Http3ExperimentalProfile.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/http3/Http3ExperimentalProfile.java new file mode 100644 index 00000000..e4b36721 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/http3/Http3ExperimentalProfile.java @@ -0,0 +1,67 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.http3; + +import java.util.List; +import java.util.Objects; +import java.util.Set; + +/** + * WebSocket over HTTP/3 per RFC 9220. Experimental, and labelled so in the type. + * + *

"Experimental" here is a support statement, not a maturity opinion. RFC 9220 extends the RFC + * 8441 mechanism to HTTP/3, which means it inherits every hop-support problem HTTP/2 has and adds + * QUIC on top: a UDP transport that a meaningful fraction of corporate networks block outright, and + * whose implementations differ in ways that only appear under packet loss. + * + *

{@link #promotable(boolean, boolean)} refuses promotion without a rollback path. That is the + * one requirement that cannot be waived: an experimental transport whose failure mode is "the + * connection does not establish on this network" needs a way back to HTTP/1.1 that does not require + * a deploy, because the population that cannot connect is also the population that cannot be + * surveyed. + * + * @param enabled whether the experimental listener is started + * @param quicImplementation which QUIC stack is in use, named because they differ + * @param validatedClients client stacks an end-to-end test has passed against + */ +public record Http3ExperimentalProfile( + boolean enabled, String quicImplementation, Set validatedClients) { + + public Http3ExperimentalProfile { + Objects.requireNonNull(quicImplementation, "quicImplementation"); + validatedClients = Set.copyOf(Objects.requireNonNull(validatedClients, "validatedClients")); + if (enabled && quicImplementation.isBlank()) { + throw new IllegalArgumentException( + "the QUIC implementation must be named: they differ under packet loss, and 'HTTP/3' " + + "does not say which behaviour an incident is exhibiting"); + } + } + + /** The default. */ + public static Http3ExperimentalProfile disabled() { + return new Http3ExperimentalProfile(false, "none", Set.of()); + } + + /** Never true. Advertising this as stable support is the thing the type exists to prevent. */ + public boolean stableSupport() { + return false; + } + + /** + * Whether this may be promoted to production. + * + * @param rollbackWithoutDeploy whether HTTP/3 can be turned off without shipping a build + * @param fallbackValidated whether the HTTP/1.1 path is tested and working + */ + public boolean promotable(boolean rollbackWithoutDeploy, boolean fallbackValidated) { + return enabled && rollbackWithoutDeploy && fallbackValidated && !validatedClients.isEmpty(); + } + + /** What has to be recorded separately, because a single pass/fail hides all of it. */ + public List requiredMatrixDimensions() { + return List.of( + "QUIC implementation and version", + "browser and version", + "intermediary: CDN, ingress, corporate proxy", + "network: UDP permitted, UDP blocked, UDP rate-limited", + "behaviour under packet loss and under path migration"); + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/presence/PortBackedPresenceStore.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/presence/PortBackedPresenceStore.java new file mode 100644 index 00000000..a12323b2 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/presence/PortBackedPresenceStore.java @@ -0,0 +1,98 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.presence; + +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketActorReference; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketEndpointName; +import dev.caskeleton.application.realtime.ActorFingerprint; +import dev.caskeleton.application.realtime.ConnectionRegistration; +import dev.caskeleton.application.realtime.ConnectionRegistryPort; +import dev.caskeleton.application.realtime.RealtimeChannel; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * Presence, derived from the same registry that answers "where is this actor". + * + *

Derived rather than stored separately, and that is the decision worth stating. A second store + * for "who is online" would drift from the first, and the drift would be invisible: both would look + * plausible, both would be recent, and nothing would reconcile them. Here the two answers cannot + * disagree because there is only one answer. + * + *

The connection counts are summed across nodes. An actor with two browser tabs on different + * nodes is one person with two connections, not two presences, and reporting the highest single + * node's count would under-report them while reporting the entry count would over-report. + * + *

The observation time is the oldest of the contributing entries, not the newest. + * Taking the newest would let one freshly-refreshed node make an otherwise stale picture look + * current — which is exactly the case presence must not get wrong, because a stale entry reporting + * connections is a node that stopped reporting rather than a user who is there. + */ +public final class PortBackedPresenceStore implements PresenceStore { + + private final ConnectionRegistryPort registry; + private final WebSocketEndpointName endpoint; + private final Duration idleAfter; + private final Duration staleAfter; + + /** + * @param registry where connections are reported + * @param endpoint which feed presence is read for + * @param idleAfter how long without a refresh counts as idle rather than online + * @param staleAfter how old an observation may be before it means nothing + */ + public PortBackedPresenceStore( + ConnectionRegistryPort registry, + WebSocketEndpointName endpoint, + Duration idleAfter, + Duration staleAfter) { + this.registry = Objects.requireNonNull(registry, "registry"); + this.endpoint = Objects.requireNonNull(endpoint, "endpoint"); + this.idleAfter = Objects.requireNonNull(idleAfter, "idleAfter"); + this.staleAfter = Objects.requireNonNull(staleAfter, "staleAfter"); + if (idleAfter.compareTo(staleAfter) >= 0) { + throw new IllegalArgumentException( + "idleAfter must be shorter than staleAfter, otherwise nothing is ever idle and the" + + " distinction the two states exist for is silently unreachable"); + } + } + + @Override + public PresenceSummary presenceOf(WebSocketActorReference actor, Instant now) { + Objects.requireNonNull(actor, "actor"); + Objects.requireNonNull(now, "now"); + List reported = + registry.locate( + new ActorFingerprint(actor.fingerprint()), new RealtimeChannel(endpoint.value()), now); + if (reported.isEmpty()) { + // Nothing reported is not the same as nobody connected, and the classifier is what decides + // which. An empty result with a current clock is OFFLINE; the registry degrading to empty + // during an outage produces the same answer, which is the known limitation of deriving + // presence from a cache and the reason nothing security-relevant may depend on it. + return PresenceSummary.absent(actor, now); + } + int connections = reported.stream().mapToInt(ConnectionRegistration::connectionCount).sum(); + Instant oldest = + reported.stream() + .map(ConnectionRegistration::observedAt) + .min(Instant::compareTo) + .orElse(now); + return PresenceSummary.classify(actor, connections, oldest, idleAfter, staleAfter, now); + } + + @Override + public Map presenceOf( + List actors, Instant now) { + Objects.requireNonNull(actors, "actors"); + Objects.requireNonNull(now, "now"); + // One registry read per actor. The port has no batch lookup, and adding one that fanned out to + // per-key reads underneath would hide the cost rather than remove it — a caller rendering a + // thousand-row list should see that it is asking for a thousand reads. + return actors.stream() + .distinct() + .collect(Collectors.toMap(Function.identity(), actor -> presenceOf(actor, now))); + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/presence/PresenceState.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/presence/PresenceState.java new file mode 100644 index 00000000..ce252383 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/presence/PresenceState.java @@ -0,0 +1,29 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.presence; + +/** + * What the cluster last observed about an actor's connections. + * + *

Four states rather than a boolean, because the two ways of not being online are not the same + * thing and the difference is the one that matters operationally. {@link #OFFLINE} is a fact the + * cluster reported: nothing is connected. {@link #STALE} is the absence of a fact: whatever last + * reported has stopped reporting, and the platform does not know. Collapsing them shows a user as + * offline during a Redis partition, when the truth is that everyone is still connected and the + * index went dark. + */ +public enum PresenceState { + + /** Connections exist and were reported recently. */ + ONLINE, + + /** Connections exist but nothing has been sent or received on them for a while. */ + IDLE, + + /** + * The observation is too old to act on. Says nothing about whether connections exist — only that + * the index stopped being updated. + */ + STALE, + + /** The cluster reported, recently, that nothing is connected. */ + OFFLINE +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/presence/PresenceStore.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/presence/PresenceStore.java new file mode 100644 index 00000000..a4265033 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/presence/PresenceStore.java @@ -0,0 +1,33 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.presence; + +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketActorReference; +import java.time.Instant; +import java.util.List; +import java.util.Map; + +/** + * Where presence summaries are read from. + * + *

Read-only by design. Presence is derived from the cluster session index rather than written + * separately: a second source of truth for "who is connected" would drift from the first, and the + * drift is invisible — both look plausible, and nothing reconciles them. + */ +public interface PresenceStore { + + /** + * Presence for one actor. + * + * @param actor whose presence + * @param now the instant freshness is judged against + */ + PresenceSummary presenceOf(WebSocketActorReference actor, Instant now); + + /** + * Presence for several actors at once. + * + *

Batched because the caller is almost always rendering a list, and asking per actor turns one + * screen into as many round trips as it has rows. + */ + Map presenceOf( + List actors, Instant now); +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/presence/PresenceSummary.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/presence/PresenceSummary.java new file mode 100644 index 00000000..e029fbdc --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/presence/PresenceSummary.java @@ -0,0 +1,112 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.presence; + +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketActorReference; +import java.time.Duration; +import java.time.Instant; +import java.util.Objects; + +/** + * Whether someone appears to be connected, and when that was last true. + * + *

The observation time is not decoration; it is the only honest part. Presence is derived from a + * cluster index that is itself a cache of facts owned by other machines, so it is always a + * statement about the past. A summary without a timestamp invites the reader to treat it as + * current, and it never is. + * + *

Nothing security-relevant may depend on this. "The user is online" is not evidence that a + * request came from them, that a session is valid, or that a payment may proceed — a stale entry + * says online for a node that died, and an attacker who can make a node stop reporting can + * therefore make the platform believe someone is present or absent at will. Presence answers + * "should I show a green dot", and that is the whole of it. + * + * @param actor whose presence this is, by fingerprint + * @param activeConnectionCount how many connections the cluster reported + * @param state the classification at the time it was read + * @param lastObservedAt when the underlying index entry was written + */ +public record PresenceSummary( + WebSocketActorReference actor, + int activeConnectionCount, + PresenceState state, + Instant lastObservedAt) { + + public PresenceSummary { + Objects.requireNonNull(actor, "actor"); + Objects.requireNonNull(state, "state"); + Objects.requireNonNull(lastObservedAt, "lastObservedAt"); + if (activeConnectionCount < 0) { + throw new IllegalArgumentException("a connection count cannot be negative"); + } + if (state == PresenceState.OFFLINE && activeConnectionCount > 0) { + throw new IllegalArgumentException( + "offline with live connections is a contradiction, and it is the shape a caller ends up " + + "with when it sets the state by hand instead of classifying"); + } + } + + /** + * Classify a raw observation. + * + *

Staleness is checked before anything else, deliberately. An old entry reporting three + * connections is a node that stopped reporting, not a user with three connections, and reading + * the count first is exactly how that becomes a green dot for a machine that is gone. + * + * @param actor whose presence + * @param activeConnectionCount connections reported by the index + * @param lastObservedAt when the index entry was written + * @param idleAfter how long without an observation refresh counts as idle rather than online + * @param staleAfter how old an observation may be before it means nothing + * @param now the current instant + */ + public static PresenceSummary classify( + WebSocketActorReference actor, + int activeConnectionCount, + Instant lastObservedAt, + Duration idleAfter, + Duration staleAfter, + Instant now) { + Objects.requireNonNull(idleAfter, "idleAfter"); + Objects.requireNonNull(staleAfter, "staleAfter"); + if (idleAfter.compareTo(staleAfter) >= 0) { + throw new IllegalArgumentException( + "idleAfter must be shorter than staleAfter, otherwise nothing is ever idle and the " + + "distinction the two states exist for is silently unreachable"); + } + Instant reference = Objects.requireNonNull(now, "now"); + PresenceState state; + if (!reference.isBefore(lastObservedAt.plus(staleAfter))) { + state = PresenceState.STALE; + } else if (activeConnectionCount == 0) { + state = PresenceState.OFFLINE; + } else if (!reference.isBefore(lastObservedAt.plus(idleAfter))) { + state = PresenceState.IDLE; + } else { + state = PresenceState.ONLINE; + } + // A stale observation keeps its reported count so an operator can see what the index last + // said, but callers are steered to appearsOnline() rather than to the number. + return new PresenceSummary( + actor, state == PresenceState.OFFLINE ? 0 : activeConnectionCount, state, lastObservedAt); + } + + /** Nobody is reported connected. */ + public static PresenceSummary absent(WebSocketActorReference actor, Instant observedAt) { + return new PresenceSummary(actor, 0, PresenceState.OFFLINE, observedAt); + } + + /** Whether the observation is recent enough to be worth anything. */ + public boolean fresh() { + return state != PresenceState.STALE; + } + + /** + * Whether to show the actor as present. + * + *

{@link PresenceState#IDLE} counts: the connection is there, the person is not typing. A UI + * that wants to distinguish the two should read {@link #state()} rather than have this method + * decide for it. + */ + public boolean appearsOnline() { + return state == PresenceState.ONLINE || state == PresenceState.IDLE; + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/release/AdvancedPromotionGate.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/release/AdvancedPromotionGate.java new file mode 100644 index 00000000..e5b2b7f2 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/release/AdvancedPromotionGate.java @@ -0,0 +1,120 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.release; + +import dev.caskeleton.adapter.inbound.websocket.advanced.WebSocketAdvancedCapability; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +/** + * What one Advanced capability must show before it goes to production. + * + *

Per capability, not per release. They share a feature-flag mechanism and nothing else: resume + * adds a token and a store, the cluster fan-out adds a network dependency to the delivery path, + * compression changes the memory profile of every connection. Promoting them together means the + * evidence for the cheapest one is treated as evidence for the most dangerous. + * + *

{@code stableArtifactUnchanged} is the condition that catches the failure this whole + * separation exists for. If turning on an Advanced capability changed the Stable wire contract or + * its dependency graph, then Stable was not independent of it, and every deployment that did not + * enable the capability has been changed anyway. + * + * @param requiredSuites the test suites that must have passed + * @param minimumSoak how long it must run under real traffic before promotion + * @param rollbackValidated whether turning it off again was actually exercised + * @param stableArtifactUnchanged whether Stable's wire contract and dependency graph are untouched + */ +public record AdvancedPromotionGate( + Set requiredSuites, + Duration minimumSoak, + boolean rollbackValidated, + boolean stableArtifactUnchanged) { + + public AdvancedPromotionGate { + requiredSuites = Set.copyOf(Objects.requireNonNull(requiredSuites, "requiredSuites")); + Objects.requireNonNull(minimumSoak, "minimumSoak"); + if (requiredSuites.isEmpty()) { + throw new IllegalArgumentException( + "a gate requiring no suite passes everything, which is worse than no gate because it " + + "reads as one"); + } + if (minimumSoak.isNegative() || minimumSoak.isZero()) { + throw new IllegalArgumentException( + "a zero soak is a promotion on the strength of a green build; the failures these " + + "capabilities have are the ones that need hours of real traffic to appear"); + } + } + + /** The gate for a capability, with the suites its own failure modes need. */ + public static AdvancedPromotionGate forCapability(WebSocketAdvancedCapability capability) { + Objects.requireNonNull(capability, "capability"); + return new AdvancedPromotionGate(suitesFor(capability), soakFor(capability), false, false); + } + + private static Set suitesFor(WebSocketAdvancedCapability capability) { + return switch (capability) { + case RESUME -> + Set.of("websocket:test", "websocketJettyTest", "resume-history-loss", "resume-replay"); + case CLUSTER_REDIS, CLUSTER_MESSAGING, PRESENCE -> + Set.of("websocket:test", "multi-node-fanout", "node-loss", "index-partition"); + case STOMP, BROKER_RELAY_RABBIT -> + Set.of("websocket:test", "broker-outage", "broker-reconnect", "user-destination"); + case COMPRESSION -> Set.of("websocket:test", "decompression-bound", "memory-under-load"); + case HTTP2_COMPAT, HTTP3_EXPERIMENTAL -> + Set.of( + "websocket:test", "websocketNginxTest", "proxy-matrix", "classic-upgrade-fallback"); + default -> Set.of("websocket:test", "websocketJettyTest"); + }; + } + + private static Duration soakFor(WebSocketAdvancedCapability capability) { + // Longer where the failure needs time or a real population to appear: a fan-out duplicate + // needs a rebalance, a memory leak needs connections that stay open, a proxy incompatibility + // needs the client population that has that proxy. + return switch (capability) { + case CLUSTER_REDIS, CLUSTER_MESSAGING, COMPRESSION -> Duration.ofHours(24); + case HTTP2_COMPAT, HTTP3_EXPERIMENTAL -> Duration.ofDays(7); + default -> Duration.ofHours(8); + }; + } + + /** + * Why this capability may not be promoted yet. + * + *

Returns the reasons rather than a boolean, because "not yet" without a list is a gate + * somebody works around instead of satisfying. + * + * @param passedSuites which suites actually passed + * @param observedSoak how long it has run under real traffic + */ + public List blockers(Set passedSuites, Duration observedSoak) { + Objects.requireNonNull(passedSuites, "passedSuites"); + Objects.requireNonNull(observedSoak, "observedSoak"); + List blockers = new ArrayList<>(); + List missing = + requiredSuites.stream().filter(s -> !passedSuites.contains(s)).sorted().toList(); + if (!missing.isEmpty()) { + blockers.add("suites not passed: " + missing); + } + if (observedSoak.compareTo(minimumSoak) < 0) { + blockers.add("soak " + observedSoak + " is short of the required " + minimumSoak); + } + if (!rollbackValidated) { + blockers.add( + "rollback not exercised; a flag nobody has turned off is not known to turn off, and the " + + "moment it is needed is the worst time to find out"); + } + if (!stableArtifactUnchanged) { + blockers.add( + "the Stable wire contract or dependency graph changed, so Stable is not independent of " + + "this capability and deployments that did not enable it are affected anyway"); + } + return List.copyOf(blockers); + } + + /** Whether promotion may proceed. */ + public boolean promotable(Set passedSuites, Duration observedSoak) { + return blockers(passedSuites, observedSoak).isEmpty(); + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/resume/PortBackedReplayAvailability.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/resume/PortBackedReplayAvailability.java new file mode 100644 index 00000000..b2c47ec6 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/resume/PortBackedReplayAvailability.java @@ -0,0 +1,42 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.resume; + +import dev.caskeleton.application.realtime.LiveEventReplayPort; +import java.util.Objects; +import java.util.Optional; + +/** + * Replay availability, read from the durable event log. + * + *

The distinction this preserves is the one {@link ReplayAvailability} exists for: a stream + * nobody has heard of and a stream whose history has aged out are different answers. The port's + * window returns no bounds for the first and a first position for the second, and mapping both to + * "0" — which is what a naive implementation does — would let a resume for a stream that never + * existed look like a complete replay of nothing. + * + *

A store that is unreachable answers empty, which the coordinator treats as "cannot resume" and + * turns into a resynchronise. That is the safe direction: the alternative is honouring a token + * whose position may have been evicted, which delivers a stream with a hole the client cannot see. + */ +public final class PortBackedReplayAvailability implements ReplayAvailability { + + private final LiveEventReplayPort replay; + + public PortBackedReplayAvailability(LiveEventReplayPort replay) { + this.replay = Objects.requireNonNull(replay, "replay"); + } + + @Override + public Optional earliestAvailable(String streamId) { + if (streamId == null || streamId.isBlank()) { + return Optional.empty(); + } + try { + return replay.window(streamId).earliestRetained(); + } catch (RuntimeException unavailable) { + // Degraded to "cannot resume". A resynchronise costs the client a re-read; honouring a token + // we cannot verify costs it a silent gap. + Objects.requireNonNull(unavailable); + return Optional.empty(); + } + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/resume/ReplayAvailability.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/resume/ReplayAvailability.java new file mode 100644 index 00000000..4f3fb38e --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/resume/ReplayAvailability.java @@ -0,0 +1,35 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.resume; + +import java.util.Map; +import java.util.Optional; + +/** + * How far back each stream can still be replayed. + * + *

A port, because the answer lives wherever the messages were retained — a durable log, a + * bounded in-memory ring, a broker's own retention. What the coordinator needs from all of them is + * the same single fact: is position N still there. + */ +@FunctionalInterface +public interface ReplayAvailability { + + /** + * The oldest position still available on a stream, or empty when the stream is unknown. + * + *

A stream the source has never heard of is not the same as one whose history has aged out, + * and answering 0 for both would let a resume for a stream that no longer exists look like a + * complete replay of nothing. + */ + Optional earliestAvailable(String streamId); + + /** An availability that has nothing, for a deployment with no replay store wired. */ + static ReplayAvailability none() { + return streamId -> Optional.empty(); + } + + /** An availability backed by a fixed map, for tests and for a static retention window. */ + static ReplayAvailability of(Map earliestByStream) { + Map copy = Map.copyOf(earliestByStream); + return streamId -> Optional.ofNullable(copy.get(streamId)); + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/resume/ResumeCoordinator.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/resume/ResumeCoordinator.java new file mode 100644 index 00000000..424ff3e1 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/resume/ResumeCoordinator.java @@ -0,0 +1,111 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.resume; + +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketActorReference; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketEndpointName; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketSessionId; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketSubprotocolName; +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; + +/** + * Decides what a reconnecting client gets. + * + *

The checks run in an order that matters. Authorisation is re-evaluated before availability, + * because a resume is the one moment a long-lived session's permissions are re-examined — the whole + * point of a session that survives a reconnect is that it does not re-authenticate, so if the + * resume path does not check, a revoked permission survives until the client stops reconnecting. + * Checking availability first would spend a replay-store lookup on a caller who is not allowed to + * have the answer, and worse, a "your history has aged out" reply confirms the session exists. + * + *

Availability is then checked per stream, and one aged-out stream forces a snapshot for all of + * them. Replaying the streams that are available and snapshotting the rest hands the client a + * mixture it has no way to reason about: part of its state is continuous and part is a new + * baseline, with nothing in the protocol saying which is which. + */ +public final class ResumeCoordinator { + + private final ReplayAvailability availability; + + /** + * A coordinator over one replay source. + * + * @param availability how far back each stream can be replayed + */ + public ResumeCoordinator(ReplayAvailability availability) { + this.availability = Objects.requireNonNull(availability, "availability"); + } + + /** + * What this client gets. + * + * @param payload what the verified token asserts + * @param caller who is connecting now + * @param endpoint which endpoint they connected to + * @param negotiated what was negotiated on this connection + * @param currentAuthorities what the caller holds now, which may differ from when they left + * @param requiredAuthorities what the endpoint demands + * @param currentPositions the latest position on each stream + * @param now the current instant + */ + public ResumeDecision decide( + ResumeTokenPayload payload, + WebSocketActorReference caller, + WebSocketEndpointName endpoint, + WebSocketSubprotocolName negotiated, + Set currentAuthorities, + Set requiredAuthorities, + Map currentPositions, + Instant now) { + Objects.requireNonNull(payload, "payload"); + Objects.requireNonNull(currentAuthorities, "currentAuthorities"); + Objects.requireNonNull(requiredAuthorities, "requiredAuthorities"); + Objects.requireNonNull(currentPositions, "currentPositions"); + + if (!payload.validAt(now)) { + return ResumeDecision.refused("the resume token has expired"); + } + if (!payload.authorises(payload.sessionId(), caller, endpoint, negotiated)) { + return ResumeDecision.refused("this token does not authorise this connection"); + } + if (!currentAuthorities.containsAll(requiredAuthorities)) { + // The reason the check is here at all. A session that survives a reconnect does not + // re-authenticate, so without this a permission revoked while the client was away comes back + // with it and stays until it stops reconnecting. + return ResumeDecision.refused( + "the caller no longer holds the authorities this endpoint needs"); + } + + Map replayFrom = new LinkedHashMap<>(); + for (Map.Entry entry : payload.streamPositions().entrySet()) { + Optional earliest = availability.earliestAvailable(entry.getKey()); + if (earliest.isEmpty()) { + return ResumeDecision.snapshot( + currentPositions, "stream " + entry.getKey() + " is not replayable"); + } + if (entry.getValue() + 1 < earliest.get()) { + // One aged-out stream forces a snapshot for all of them. Mixing replayed and snapshotted + // streams hands the client state it cannot reason about — part continuous, part a new + // baseline, with nothing saying which. + return ResumeDecision.snapshot( + currentPositions, + "stream " + entry.getKey() + " has aged out past position " + entry.getValue()); + } + replayFrom.put(entry.getKey(), entry.getValue() + 1); + } + return ResumeDecision.replay(replayFrom, "the missed range is available on every stream"); + } + + /** + * Whether a session may be resumed at all. + * + *

Separate from {@link #decide} so a caller can answer "is resume even on" without building a + * decision, and so the flag check is not buried inside the policy. + */ + public static boolean resumable(WebSocketSessionId sessionId, boolean capabilityEnabled) { + return capabilityEnabled && sessionId != null; + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/resume/ResumeDecision.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/resume/ResumeDecision.java new file mode 100644 index 00000000..5d7bbca8 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/resume/ResumeDecision.java @@ -0,0 +1,87 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.resume; + +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +/** + * What the server will actually do about a resume request. + * + *

Three answers, and the middle one is the reason this is not a boolean. A client whose missed + * range is still available gets replayed; a client whose range has aged out gets a snapshot and a + * new baseline; a client that may not resume at all starts fresh. Collapsing snapshot into either + * neighbour is wrong in a way the client cannot detect: told "replayed" after a snapshot it + * believes it has continuous history it does not have, and told "start fresh" it discards state it + * did not need to. + * + * @param outcome what the server decided + * @param replayFrom the positions to replay from, when replaying + * @param snapshotBaseline the positions the snapshot is current as of, when snapshotting + * @param reason why, in terms safe to publish + */ +public record ResumeDecision( + Outcome outcome, + Optional> replayFrom, + Optional> snapshotBaseline, + String reason) { + + /** What the server decided. */ + public enum Outcome { + + /** The missed range is available. The client keeps its state and receives what it missed. */ + REPLAY, + + /** + * The missed range has aged out. The client receives current state and a new baseline. + * + *

Its own outcome because the client must discard what it had. Told this is a replay, it + * would merge a snapshot into stale history and believe the result is continuous. + */ + SNAPSHOT, + + /** The client may not resume. It starts a new session. */ + REFUSED + } + + public ResumeDecision { + Objects.requireNonNull(outcome, "outcome"); + Objects.requireNonNull(replayFrom, "replayFrom"); + Objects.requireNonNull(snapshotBaseline, "snapshotBaseline"); + Objects.requireNonNull(reason, "reason"); + if ((outcome == Outcome.REPLAY) != replayFrom.isPresent()) { + throw new IllegalArgumentException("a replay decision carries the positions to replay from"); + } + if ((outcome == Outcome.SNAPSHOT) != snapshotBaseline.isPresent()) { + // Without a baseline the client has current state and no idea what sequence follows it, so + // the first message after the snapshot either looks like a gap or is silently misordered. + throw new IllegalArgumentException( + "a snapshot decision must state the baseline it is current as of, or the first message" + + " after it reads as a gap"); + } + if (reason.isBlank()) { + throw new IllegalArgumentException("a resume decision without a reason cannot be diagnosed"); + } + replayFrom = replayFrom.map(Map::copyOf); + snapshotBaseline = snapshotBaseline.map(Map::copyOf); + } + + /** The missed range is available. */ + public static ResumeDecision replay(Map from, String reason) { + return new ResumeDecision(Outcome.REPLAY, Optional.of(from), Optional.empty(), reason); + } + + /** The range has aged out; send current state and a new baseline. */ + public static ResumeDecision snapshot(Map baseline, String reason) { + return new ResumeDecision(Outcome.SNAPSHOT, Optional.empty(), Optional.of(baseline), reason); + } + + /** The client may not resume. */ + public static ResumeDecision refused(String reason) { + return new ResumeDecision(Outcome.REFUSED, Optional.empty(), Optional.empty(), reason); + } + + /** Whether the client keeps the state it already had. */ + public boolean clientKeepsItsState() { + return outcome == Outcome.REPLAY; + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/resume/ResumeTokenCodec.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/resume/ResumeTokenCodec.java new file mode 100644 index 00000000..d777d592 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/resume/ResumeTokenCodec.java @@ -0,0 +1,217 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.resume; + +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketActorReference; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketEndpointName; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketSessionId; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketSubprotocolName; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.Instant; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import javax.crypto.Mac; +import javax.crypto.SecretKey; + +/** + * Encodes and verifies a resume token. + * + *

Signed, not encrypted, and the difference is deliberate. The payload's contents — a session + * id, an actor fingerprint, stream positions — are things the client already knows about itself, so + * hiding them buys nothing; what matters is that the client cannot change them. Encrypting as well + * would add a second key management problem for no property the platform needs. + * + *

The version and key id sit outside the signed payload because verification has to read them + * before it can verify anything — it needs to know which key and which format. That is safe + * precisely because the signature covers the payload: changing the key id makes verification fail + * rather than succeed with a different key's expectations. + */ +public final class ResumeTokenCodec { + + /** The current token format. */ + public static final int VERSION = 1; + + private static final String ALGORITHM = "HmacSHA256"; + private static final char FIELD = '\u001f'; + private static final char SECTION = '\u001e'; + + private final ResumeTokenKeyRing keyRing; + + /** + * A codec over one key ring. + * + * @param keyRing which keys sign and verify + */ + public ResumeTokenCodec(ResumeTokenKeyRing keyRing) { + this.keyRing = Objects.requireNonNull(keyRing, "keyRing"); + } + + /** + * Mints a token for a session. + * + * @param payload what the token asserts + */ + public String encode(ResumeTokenPayload payload) { + Objects.requireNonNull(payload, "payload"); + String body = render(payload); + String signed = VERSION + String.valueOf(SECTION) + keyRing.currentKeyId() + SECTION + body; + return base64(signed) + "." + base64(sign(signed, keyRing.signingKey())); + } + + /** + * Verifies a token and says exactly why when it fails. + * + * @param token the token as presented + * @param now the current instant + */ + public Verification decode(String token, Instant now) { + if (token == null || token.isBlank()) { + return Verification.rejected(ResumeTokenOutcome.TAMPERED); + } + int separator = token.lastIndexOf('.'); + if (separator < 0) { + return Verification.rejected(ResumeTokenOutcome.TAMPERED); + } + String signed; + String signature; + try { + signed = + new String( + Base64.getUrlDecoder().decode(token.substring(0, separator)), StandardCharsets.UTF_8); + signature = token.substring(separator + 1); + } catch (IllegalArgumentException malformed) { + return Verification.rejected(ResumeTokenOutcome.TAMPERED); + } + + String[] sections = signed.split(String.valueOf(SECTION), 3); + if (sections.length != 3) { + return Verification.rejected(ResumeTokenOutcome.TAMPERED); + } + // Version before key, because an unknown version's key field may not even mean what this code + // thinks it does. + int version; + try { + version = Integer.parseInt(sections[0]); + } catch (NumberFormatException malformed) { + return Verification.rejected(ResumeTokenOutcome.TAMPERED); + } + if (version != VERSION) { + return Verification.rejected(ResumeTokenOutcome.UNSUPPORTED_VERSION); + } + + Optional key = keyRing.verificationKey(sections[1]); + if (key.isEmpty()) { + return Verification.rejected(ResumeTokenOutcome.UNKNOWN_KEY); + } + + // Constant-time. A byte-by-byte comparison that returns early leaks the correct signature one + // byte at a time to anyone willing to measure, and a resume token is presented as often as the + // attacker likes. + if (!MessageDigest.isEqual( + base64(sign(signed, key.get())).getBytes(StandardCharsets.UTF_8), + signature.getBytes(StandardCharsets.UTF_8))) { + return Verification.rejected(ResumeTokenOutcome.TAMPERED); + } + + ResumeTokenPayload payload; + try { + payload = parse(sections[2]); + } catch (RuntimeException malformed) { + return Verification.rejected(ResumeTokenOutcome.TAMPERED); + } + // Expiry after the signature, never before: an unverified payload's expiry is a number the + // client chose. + if (!payload.validAt(now)) { + return Verification.rejected(ResumeTokenOutcome.EXPIRED); + } + return Verification.accepted(payload); + } + + /** + * The outcome of verifying a token. + * + * @param outcome why it was or was not accepted + * @param payload what it asserted, when it was accepted + */ + public record Verification(ResumeTokenOutcome outcome, Optional payload) { + + public Verification { + Objects.requireNonNull(outcome, "outcome"); + Objects.requireNonNull(payload, "payload"); + if ((outcome == ResumeTokenOutcome.ACCEPTED) != payload.isPresent()) { + throw new IllegalArgumentException( + "an accepted verification carries a payload and a rejected one does not"); + } + } + + static Verification accepted(ResumeTokenPayload payload) { + return new Verification(ResumeTokenOutcome.ACCEPTED, Optional.of(payload)); + } + + static Verification rejected(ResumeTokenOutcome outcome) { + return new Verification(outcome, Optional.empty()); + } + } + + private static String render(ResumeTokenPayload payload) { + StringBuilder body = new StringBuilder(); + body.append(payload.sessionId().value()).append(FIELD); + body.append(payload.actor().fingerprint()).append(FIELD); + body.append(payload.endpoint().value()).append(FIELD); + body.append(payload.subprotocol().value()).append(FIELD); + body.append(payload.issuedAt().toEpochMilli()).append(FIELD); + body.append(payload.expiresAt().toEpochMilli()).append(FIELD); + // Sorted, so the same payload always renders identically and a signature is reproducible. + new java.util.TreeMap<>(payload.streamPositions()) + .forEach( + (stream, position) -> body.append(stream).append('=').append(position).append(',')); + return body.toString(); + } + + private static ResumeTokenPayload parse(String body) { + String[] fields = body.split(String.valueOf(FIELD), -1); + Map positions = new LinkedHashMap<>(); + if (fields.length > 6 && !fields[6].isEmpty()) { + // -1 limit, so a trailing separator does not silently drop the last entry — the rendered + // form always ends with one, and split's default would discard it along with any empty + // position that ought to have been an error. + for (String entry : fields[6].split(",", -1)) { + if (entry.isEmpty()) { + continue; + } + int equals = entry.lastIndexOf('='); + positions.put(entry.substring(0, equals), Long.parseLong(entry.substring(equals + 1))); + } + } + return new ResumeTokenPayload( + new WebSocketSessionId(fields[0]), + new WebSocketActorReference(fields[1]), + new WebSocketEndpointName(fields[2]), + new WebSocketSubprotocolName(fields[3]), + positions, + Instant.ofEpochMilli(Long.parseLong(fields[4])), + Instant.ofEpochMilli(Long.parseLong(fields[5]))); + } + + private static byte[] sign(String signed, SecretKey key) { + try { + Mac mac = Mac.getInstance(ALGORITHM); + mac.init(key); + return mac.doFinal(signed.getBytes(StandardCharsets.UTF_8)); + } catch (java.security.GeneralSecurityException impossible) { + throw new IllegalStateException("HmacSHA256 is required of every JVM", impossible); + } + } + + private static String base64(String value) { + return Base64.getUrlEncoder() + .withoutPadding() + .encodeToString(value.getBytes(StandardCharsets.UTF_8)); + } + + private static String base64(byte[] value) { + return Base64.getUrlEncoder().withoutPadding().encodeToString(value); + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/resume/ResumeTokenKeyRing.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/resume/ResumeTokenKeyRing.java new file mode 100644 index 00000000..67e8aeec --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/resume/ResumeTokenKeyRing.java @@ -0,0 +1,87 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.resume; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import javax.crypto.SecretKey; + +/** + * The signing keys, with exactly one current and any number still accepted. + * + *

A ring rather than a key, because rotation is otherwise an outage. A token is minted with the + * current key and lives for its window; replacing the key with a single new one invalidates every + * token in flight, and every client holding one is told to start a fresh session at the same + * moment. On a platform whose whole purpose is surviving a reconnect, that is the failure the + * feature was added to prevent. + * + *

Retired keys therefore verify and do not sign. The ring is what lets a rotation be a + * deployment rather than an event. + */ +public final class ResumeTokenKeyRing { + + private final String currentKeyId; + private final Map keys; + + private ResumeTokenKeyRing(String currentKeyId, Map keys) { + this.currentKeyId = currentKeyId; + this.keys = keys; + } + + /** + * A ring with one signing key and any number of verify-only keys. + * + * @param currentKeyId which key signs + * @param keys every key that verifies, including the current one + */ + public static ResumeTokenKeyRing of(String currentKeyId, Map keys) { + Objects.requireNonNull(currentKeyId, "currentKeyId"); + Objects.requireNonNull(keys, "keys"); + if (!keys.containsKey(currentKeyId)) { + throw new IllegalArgumentException( + "the current key " + currentKeyId + " is not in the ring, so nothing it signs verifies"); + } + Map copy = new LinkedHashMap<>(keys); + copy.forEach( + (id, key) -> { + Objects.requireNonNull(id, "key id"); + Objects.requireNonNull(key, "key"); + if (key.getEncoded() != null && key.getEncoded().length < 32) { + // 256 bits. A resume token authorises reading a session's history, so a key short + // enough to brute force is a key that hands over other people's messages. + throw new IllegalArgumentException( + "key " + + id + + " is shorter than 256 bits; a resume token authorises reading a" + + " session's history, so a forgeable one hands over somebody else's messages"); + } + }); + return new ResumeTokenKeyRing(currentKeyId, Map.copyOf(copy)); + } + + /** The key that signs new tokens. */ + public SecretKey signingKey() { + return keys.get(currentKeyId); + } + + /** The id of the signing key, carried in the token so verification knows which to use. */ + public String currentKeyId() { + return currentKeyId; + } + + /** + * The key a token names, if this ring still has it. + * + *

Empty means {@link ResumeTokenOutcome#UNKNOWN_KEY} — which is usually a rotation that + * retired a key still in circulation, and occasionally a forgery. The ring cannot tell the + * difference and does not pretend to. + */ + public Optional verificationKey(String keyId) { + return Optional.ofNullable(keys.get(keyId)); + } + + /** How many keys still verify. */ + public int size() { + return keys.size(); + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/resume/ResumeTokenOutcome.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/resume/ResumeTokenOutcome.java new file mode 100644 index 00000000..99a80634 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/resume/ResumeTokenOutcome.java @@ -0,0 +1,56 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.resume; + +/** + * Why a resume token was or was not accepted. + * + *

The distinctions are not for the client, which is told the same thing for most of them — they + * are for the operator. "Clients cannot resume" has completely different causes and remedies + * depending on which of these is climbing: an expired-token spike is a client that has been offline + * longer than the window, an unknown-key spike is a key rotation that removed a key still in use, + * and a replay spike is either a client bug or somebody replaying captured tokens. + */ +public enum ResumeTokenOutcome { + + /** Valid, unspent, and within its window. */ + ACCEPTED, + + /** + * Signed with a key this deployment does not have. + * + *

Usually a rotation that retired a key still in circulation, occasionally a forgery. The two + * are indistinguishable from the token alone, which is why this is its own outcome rather than + * being folded into a generic rejection. + */ + UNKNOWN_KEY, + + /** A token version this deployment does not understand. */ + UNSUPPORTED_VERSION, + + /** Well-formed, correctly signed, and past its expiry. */ + EXPIRED, + + /** + * Already used. + * + *

Distinct from expired because it means something different: a token is single-use, so a + * second presentation is either a client that retried without minting a new one or somebody + * replaying a captured token. Both need looking at; neither is normal. + */ + REPLAYED, + + /** Correctly signed but bound to a different actor, endpoint, session or subprotocol. */ + NOT_AUTHORISED, + + /** The signature does not verify. */ + TAMPERED; + + /** + * Whether the client should mint a new token and continue. + * + *

The only outcomes worth distinguishing to a client. Everything else is "start a fresh + * session", and telling a client which of the failures it hit would tell an attacker the same. + */ + public boolean clientMayRetryWithNewToken() { + return this == EXPIRED || this == UNKNOWN_KEY || this == UNSUPPORTED_VERSION; + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/resume/ResumeTokenPayload.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/resume/ResumeTokenPayload.java new file mode 100644 index 00000000..695f7a0c --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/resume/ResumeTokenPayload.java @@ -0,0 +1,83 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.resume; + +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketActorReference; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketEndpointName; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketSessionId; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketSubprotocolName; +import java.time.Instant; +import java.util.Map; +import java.util.Objects; + +/** + * What a resume token asserts. + * + *

Bound to four things, and each binding closes a specific hole. Without the actor it is a + * bearer credential — whoever holds it resumes as whoever created it. Without the endpoint, a token + * from a low-privilege feed resumes a privileged one. Without the subprotocol, a client resumes + * into a session whose message format it no longer speaks and every subsequent frame fails to + * decode. Without the session id there is nothing to resume. + * + *

The stream positions are the substance: a resume is "I last saw N on each of these streams", + * and the server sends what came after. Carrying them in the token rather than trusting the client + * to restate them is what makes a replayed token detectable — a token presented twice asks for the + * same range twice, which is a different thing from a client legitimately catching up. + * + * @param sessionId the session being resumed + * @param actor who may resume it + * @param endpoint which endpoint it belongs to + * @param subprotocol the format it was negotiated with + * @param streamPositions the last position seen on each stream + * @param issuedAt when the token was minted + * @param expiresAt when it stops working + */ +public record ResumeTokenPayload( + WebSocketSessionId sessionId, + WebSocketActorReference actor, + WebSocketEndpointName endpoint, + WebSocketSubprotocolName subprotocol, + Map streamPositions, + Instant issuedAt, + Instant expiresAt) { + + public ResumeTokenPayload { + Objects.requireNonNull(sessionId, "sessionId"); + Objects.requireNonNull(actor, "actor"); + Objects.requireNonNull(endpoint, "endpoint"); + Objects.requireNonNull(subprotocol, "subprotocol"); + Objects.requireNonNull(streamPositions, "streamPositions"); + Objects.requireNonNull(issuedAt, "issuedAt"); + Objects.requireNonNull(expiresAt, "expiresAt"); + streamPositions = Map.copyOf(streamPositions); + if (!expiresAt.isAfter(issuedAt)) { + throw new IllegalArgumentException("a token that expires when minted resumes nothing"); + } + streamPositions.forEach( + (stream, position) -> { + if (position < 0) { + throw new IllegalArgumentException("a stream position cannot be negative"); + } + }); + } + + /** Whether the token is inside its window. */ + public boolean validAt(Instant now) { + return !now.isBefore(issuedAt) && now.isBefore(expiresAt); + } + + /** + * Whether this token may resume a given session for a given caller on a given endpoint. + * + *

All four checked together, because any one of them alone is a hole and checking three of + * four reads as thorough. + */ + public boolean authorises( + WebSocketSessionId targetSession, + WebSocketActorReference caller, + WebSocketEndpointName targetEndpoint, + WebSocketSubprotocolName negotiated) { + return sessionId.equals(targetSession) + && actor.equals(caller) + && endpoint.equals(targetEndpoint) + && subprotocol.equals(negotiated); + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/sockjs/SockJsCompatibilityProfile.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/sockjs/SockJsCompatibilityProfile.java new file mode 100644 index 00000000..f16b4b75 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/sockjs/SockJsCompatibilityProfile.java @@ -0,0 +1,90 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.sockjs; + +import dev.caskeleton.adapter.inbound.websocket.advanced.compression.EndpointContentClass; +import java.time.Duration; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +/** + * SockJS fallback for clients that cannot open a raw WebSocket. + * + *

Off for a new service. It exists for a browser population that a given deployment actually + * has, and enabling it without one costs the whole surface below for nobody. + * + *

What is easy to miss is that the fallback transports are HTTP requests, and HTTP requests + * carry cookies. A raw WebSocket handshake is not subject to the same-origin policy and is + * therefore checked by origin explicitly; the SockJS fallbacks are ordinary cross-origin requests + * that a browser will attach credentials to, which puts CSRF back on a surface that did not have + * it. So this profile does not get to relax origin checking, and the session cookie it needs is + * bounded in lifetime rather than left to the container default. + * + * @param enabled whether the fallback endpoint is exposed + * @param transports which fallbacks are offered + * @param sessionCookieLifetime how long the SockJS session cookie is valid + */ +public record SockJsCompatibilityProfile( + boolean enabled, Set transports, Duration sessionCookieLifetime) { + + public SockJsCompatibilityProfile { + transports = Set.copyOf(Objects.requireNonNull(transports, "transports")); + Objects.requireNonNull(sessionCookieLifetime, "sessionCookieLifetime"); + if (enabled) { + if (transports.isEmpty()) { + throw new IllegalArgumentException( + "SockJS enabled with no transport offers nothing and still exposes the endpoint"); + } + if (sessionCookieLifetime.isNegative() || sessionCookieLifetime.isZero()) { + throw new IllegalArgumentException( + "the SockJS session cookie must expire; a session-lifetime cookie on a fallback " + + "transport outlives the connection it was issued for"); + } + if (sessionCookieLifetime.compareTo(Duration.ofHours(12)) > 0) { + throw new IllegalArgumentException( + "a SockJS session cookie valid for longer than half a day is a bearer credential for " + + "a transport session, kept long after the transport session ended"); + } + } + } + + /** The default. */ + public static SockJsCompatibilityProfile disabled() { + return new SockJsCompatibilityProfile(false, Set.of(), Duration.ofHours(1)); + } + + /** + * Whether this profile may serve an endpoint carrying the given content. + * + *

Refused for a JSONP-polling offer on anything sensitive: JSONP executes server-supplied + * script in the page, so a sensitive payload becomes script the page runs, readable by anything + * else running there. + */ + public boolean mayServe(EndpointContentClass contentClass) { + Objects.requireNonNull(contentClass, "contentClass"); + if (!enabled) { + return false; + } + boolean sensitive = + contentClass == EndpointContentClass.SENSITIVE + || contentClass == EndpointContentClass.SENSITIVE_WITH_ATTACKER_INFLUENCE; + return !sensitive + || transports.stream().noneMatch(SockJsTransport::executesServerSuppliedScript); + } + + /** The checks that stay exactly as strict as they are for a raw WebSocket. */ + public List inheritedFromStable() { + return List.of( + "origin: the same allowlist, checked on every fallback request rather than once at " + + "handshake", + "CSRF: the fallbacks are credentialed cross-origin HTTP requests, so the token requirement " + + "applies where it does not to a raw handshake", + "connection budget: a fallback session counts against the same per-actor and global bounds", + "authentication: the same, and re-established per polling request rather than assumed from " + + "the session cookie"); + } + + /** The transports that a proxy response timeout will cut. */ + public List transportsExposedToProxyTimeouts() { + return transports.stream().filter(SockJsTransport::holdsResponseOpen).sorted().toList(); + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/sockjs/SockJsConfiguration.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/sockjs/SockJsConfiguration.java new file mode 100644 index 00000000..3d896dc3 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/sockjs/SockJsConfiguration.java @@ -0,0 +1,103 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.sockjs; + +import dev.caskeleton.adapter.inbound.websocket.advanced.compression.EndpointContentClass; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketEndpointCatalog; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketEndpointProfile; +import dev.caskeleton.adapter.inbound.websocket.security.WebSocketOriginPolicy; +import java.time.Duration; +import java.util.Objects; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.config.annotation.EnableWebSocket; +import org.springframework.web.socket.config.annotation.SockJsServiceRegistration; +import org.springframework.web.socket.config.annotation.WebSocketConfigurer; +import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry; + +/** + * Exposes the SockJS fallback endpoint, when a deployment has a browser population that needs it. + * + *

Off by default and gated on its own flag, because the fallbacks are not a cheaper WebSocket — + * they are HTTP requests. That is the thing that gets missed: an HTTP request carries cookies, so a + * transport a browser will attach credentials to has CSRF exposure a raw WebSocket handshake does + * not, and it is reachable by any origin the CORS configuration allows rather than only the ones + * the handshake's origin check permits. + * + *

So the origin allowlist here is the endpoint's own, not a wildcard, and the session cookie is + * disabled outright. SockJS's cookie exists to help sticky-session load balancers; a deployment + * that needs stickiness should get it from the balancer, because the cookie is a bearer credential + * for a transport session and it outlives the session it was issued for. + */ +@Configuration(proxyBeanMethods = false) +@EnableWebSocket +@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) +@ConditionalOnProperty( + prefix = "app.websocket-platform.advanced.sockjs", + name = "enabled", + havingValue = "true") +public class SockJsConfiguration implements WebSocketConfigurer { + + private final SockJsCompatibilityProfile profile; + private final WebSocketEndpointCatalog endpoints; + private final WebSocketOriginPolicy origins; + private final WebSocketHandler handler; + + public SockJsConfiguration( + SockJsCompatibilityProfile profile, + WebSocketEndpointCatalog endpoints, + WebSocketOriginPolicy origins, + WebSocketHandler handler) { + this.profile = Objects.requireNonNull(profile, "profile"); + this.endpoints = Objects.requireNonNull(endpoints, "endpoints"); + this.origins = Objects.requireNonNull(origins, "origins"); + this.handler = Objects.requireNonNull(handler, "handler"); + } + + /** The fallback set, overridable by a deployment that knows its browser population. */ + @Bean + @ConditionalOnMissingBean + static SockJsCompatibilityProfile sockJsCompatibilityProfile() { + return new SockJsCompatibilityProfile( + true, + java.util.Set.of( + SockJsTransport.WEBSOCKET, SockJsTransport.XHR_STREAMING, SockJsTransport.XHR_POLLING), + Duration.ofHours(1)); + } + + @Override + public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { + for (WebSocketEndpointProfile endpoint : endpoints.all()) { + if (!profile.mayServe(contentClassOf(endpoint))) { + // Refused per endpoint rather than globally. A JSONP-capable fallback set may serve the + // public feed and must not serve the one carrying a token, and the decision needs the + // endpoint's content in view. + continue; + } + SockJsServiceRegistration sockJs = + registry + .addHandler(handler, endpoint.path()) + // The endpoint's own allowlist, never a wildcard. A fallback transport is an + // ordinary cross-origin HTTP request, so this is the only thing standing where the + // handshake's origin check stands for a raw WebSocket. + .setAllowedOriginPatterns(origins.allowedOrigins().toArray(String[]::new)) + .withSockJS(); + // Off, not shortened. The cookie is a bearer credential for a transport session, and its + // only purpose is to help a load balancer that should be doing stickiness itself. + sockJs.setSessionCookieNeeded(false); + sockJs.setHeartbeatTime(Duration.ofSeconds(25).toMillis()); + sockJs.setDisconnectDelay(Duration.ofSeconds(5).toMillis()); + } + } + + private static EndpointContentClass contentClassOf(WebSocketEndpointProfile endpoint) { + // Derived from whether the endpoint authenticates. An endpoint that requires a credential is + // carrying something worth protecting; a public one is not. A deployment with a finer answer + // supplies its own profile. + return endpoint.requiresAuthentication() + ? EndpointContentClass.SENSITIVE + : EndpointContentClass.PUBLIC_DATA; + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/sockjs/SockJsTransport.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/sockjs/SockJsTransport.java new file mode 100644 index 00000000..2d21031c --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/sockjs/SockJsTransport.java @@ -0,0 +1,61 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.sockjs; + +/** + * The SockJS fallback transports, and what each costs. + * + *

Each is a different shape of HTTP traffic pretending to be a socket, and the differences + * matter to everything in front of the application. A streaming transport holds a response open, + * which a proxy with a response timeout will cut; a polling transport issues a request per message, + * which multiplies request count by message rate. + */ +public enum SockJsTransport { + + /** A real WebSocket. Not a fallback; listed so the set is complete. */ + WEBSOCKET(false, false), + + /** A long-lived streaming response over XHR. Cut by any proxy with a response timeout. */ + XHR_STREAMING(true, true), + + /** One request per message. Request count scales with message rate. */ + XHR_POLLING(true, false), + + /** Streaming via an EventSource. Same proxy timeout exposure as XHR streaming. */ + EVENT_SOURCE(true, true), + + /** + * A script tag per message, for browsers with no cross-origin XHR. + * + *

Executes server-supplied script in the page. Only defensible for a browser population that + * genuinely cannot do anything else. + */ + JSONP_POLLING(true, false), + + /** Streaming through a hidden iframe with an htmlfile ActiveX object. */ + HTML_FILE(true, true), + + /** An iframe hosting a real WebSocket, for pages whose own origin blocks one. */ + IFRAME_WEBSOCKET(true, false); + + private final boolean fallback; + private final boolean holdsResponseOpen; + + SockJsTransport(boolean fallback, boolean holdsResponseOpen) { + this.fallback = fallback; + this.holdsResponseOpen = holdsResponseOpen; + } + + /** Whether this is a fallback rather than a real socket. */ + public boolean fallback() { + return fallback; + } + + /** Whether it keeps an HTTP response open, and so meets proxy response timeouts. */ + public boolean holdsResponseOpen() { + return holdsResponseOpen; + } + + /** Whether it executes server-supplied script in the page. */ + public boolean executesServerSuppliedScript() { + return this == JSONP_POLLING; + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/SimpleBrokerProfile.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/SimpleBrokerProfile.java new file mode 100644 index 00000000..e91b46fe --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/SimpleBrokerProfile.java @@ -0,0 +1,59 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.stomp; + +import java.util.List; + +/** + * What Spring's in-memory simple broker can and cannot do. + * + *

Written down because the simple broker is the default, works perfectly in a single-node test, + * and fails in production in ways that look like application bugs. It holds subscriptions in one + * JVM's heap: a second node sees none of them, and a restart loses all of them. Nothing about + * either failure is visible from the destination string a developer writes. + * + * @param localOnly whether subscriptions are visible only within this JVM + * @param clusterSupported whether a second node would see the same subscriptions + * @param durableAckSupported whether an acknowledged message survives a restart + */ +public record SimpleBrokerProfile( + boolean localOnly, boolean clusterSupported, boolean durableAckSupported) { + + public SimpleBrokerProfile { + if (!localOnly || clusterSupported || durableAckSupported) { + throw new IllegalArgumentException( + "the simple broker is single-node and non-durable; a profile claiming otherwise " + + "describes a broker that does not exist, and the claim would be believed"); + } + } + + /** The only shape this profile has. */ + public static SimpleBrokerProfile inMemory() { + return new SimpleBrokerProfile(true, false, false); + } + + /** + * Whether this broker may back the named deployment. + * + *

Refused outside local and test. The simple broker in a multi-node deployment does not error + * — it delivers to whichever fraction of users happens to be on the publishing node, which reads + * as intermittent message loss and is diagnosed as anything but the broker. + * + * @param profileNames the active Spring profiles + */ + public static boolean activatableUnder(List profileNames) { + if (profileNames == null || profileNames.isEmpty()) { + // No profile is the default profile, which is a developer's machine. + return true; + } + return profileNames.stream() + .allMatch(name -> name.equals("local") || name.equals("test") || name.equals("default")); + } + + /** The limitations, in the words an operator needs to read them in. */ + public List supportMatrix() { + return List.of( + "cluster: unsupported - subscriptions live in one JVM's heap and a second node sees none", + "durable ack: unsupported - BROKER_ACK is reached and survives no restart", + "receipt: transport-level only - never promoted to APPLICATION_COMMIT", + "redelivery: none - a message written to a closed connection is gone"); + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/StompAckMode.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/StompAckMode.java new file mode 100644 index 00000000..c4cc1f3e --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/StompAckMode.java @@ -0,0 +1,76 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.stomp; + +import java.util.Locale; +import java.util.Optional; + +/** + * The three RFC-defined subscription acknowledgement modes. + * + *

Distinguished because they place responsibility for redelivery in different places, and a + * server that treats them alike is wrong for two of the three. Under {@link #AUTO} the server + * forgets the message the moment it writes it; under the other two it must hold it until the client + * says otherwise, and how much it releases on one {@code ACK} differs. + */ +public enum StompAckMode { + + /** + * The server considers a message delivered as soon as it is written. + * + *

No redelivery, ever. A client that disconnects mid-message loses it, which is acceptable for + * a ticker and not for anything a user acts on. + */ + AUTO("auto"), + + /** + * The client acknowledges, and one {@code ACK} covers every message up to and including it. + * + *

Cumulative. The trap is that an {@code ACK} for a message the client actually failed to + * process silently acknowledges everything before it too. + */ + CLIENT("client"), + + /** The client acknowledges each message on its own. No cumulative effect. */ + CLIENT_INDIVIDUAL("client-individual"); + + private final String wireName; + + StompAckMode(String wireName) { + this.wireName = wireName; + } + + /** The name as it appears in the {@code ack} header. */ + public String wireName() { + return wireName; + } + + /** Whether the server must retain the message until the client acknowledges it. */ + public boolean requiresClientAcknowledgement() { + return this != AUTO; + } + + /** Whether one acknowledgement releases every earlier message as well. */ + public boolean cumulative() { + return this == CLIENT; + } + + /** + * Parse an {@code ack} header. + * + *

Empty for anything unrecognised. The caller must refuse rather than default: defaulting an + * unknown mode to {@code auto} turns a client asking for at-least-once into one that silently + * gets at-most-once, and defaulting it to {@code client} makes the server hold messages for a + * client that will never acknowledge them. + */ + public static Optional fromWire(String header) { + if (header == null) { + return Optional.empty(); + } + String normalized = header.trim().toLowerCase(Locale.ROOT); + for (StompAckMode mode : values()) { + if (mode.wireName.equals(normalized)) { + return Optional.of(mode); + } + } + return Optional.empty(); + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/StompAckPolicy.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/StompAckPolicy.java new file mode 100644 index 00000000..7b0b5d1a --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/StompAckPolicy.java @@ -0,0 +1,79 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.stomp; + +import java.time.Duration; +import java.util.Objects; + +/** + * What each acknowledgement signal is allowed to be reported as. + * + *

The whole point is the refusal in {@link #evidenceForReceipt()}: a STOMP {@code RECEIPT} maps + * to {@link StompEvidence#PROTOCOL_RECEIPT} and can never be promoted to {@link + * StompEvidence#APPLICATION_COMMIT}, no matter how convenient that would be for a client that wants + * one signal instead of two. + * + * @param ackMode the subscription's acknowledgement mode + * @param pendingLimit how many unacknowledged messages one subscription may accumulate + * @param acknowledgementTimeout how long to wait before treating a message as unacknowledged + */ +public record StompAckPolicy( + StompAckMode ackMode, int pendingLimit, Duration acknowledgementTimeout) { + + public StompAckPolicy { + Objects.requireNonNull(ackMode, "ackMode"); + Objects.requireNonNull(acknowledgementTimeout, "acknowledgementTimeout"); + if (ackMode.requiresClientAcknowledgement() && pendingLimit < 1) { + throw new IllegalArgumentException( + "a client-acknowledged subscription with no pending allowance can never deliver"); + } + if (!ackMode.requiresClientAcknowledgement() && pendingLimit != 0) { + throw new IllegalArgumentException( + "auto acknowledgement holds nothing, so a pending limit describes a queue that does not " + + "exist"); + } + if (acknowledgementTimeout.isNegative() || acknowledgementTimeout.isZero()) { + throw new IllegalArgumentException( + "an unbounded acknowledgement wait lets one silent client pin the retained messages of " + + "every other one"); + } + } + + /** The conventional auto-mode policy: nothing retained, nothing awaited. */ + public static StompAckPolicy auto() { + return new StompAckPolicy(StompAckMode.AUTO, 0, Duration.ofSeconds(30)); + } + + /** What a {@code RECEIPT} frame proves. Fixed, and deliberately not configurable. */ + public StompEvidence evidenceForReceipt() { + return StompEvidence.PROTOCOL_RECEIPT; + } + + /** What a client {@code ACK} frame proves. */ + public StompEvidence evidenceForClientAck() { + if (!ackMode.requiresClientAcknowledgement()) { + throw new IllegalStateException( + "an auto-mode subscription receives no ACK frames, so asking what one proves means the " + + "caller has the wrong policy in hand"); + } + return StompEvidence.CLIENT_APPLIED; + } + + /** + * Whether reporting a stage is honest given what actually happened. + * + * @param observed the furthest stage genuinely reached + * @param claimed the stage a caller wants to report + */ + public boolean mayReport(StompEvidence observed, StompEvidence claimed) { + Objects.requireNonNull(observed, "observed"); + Objects.requireNonNull(claimed, "claimed"); + return !observed.wouldOverstate(claimed); + } + + /** Whether another unacknowledged message may be delivered on this subscription. */ + public boolean mayDeliver(int pending) { + if (!ackMode.requiresClientAcknowledgement()) { + return true; + } + return pending < pendingLimit; + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/StompAuthorizationDecision.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/StompAuthorizationDecision.java new file mode 100644 index 00000000..b31ae4f8 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/StompAuthorizationDecision.java @@ -0,0 +1,34 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.stomp; + +import java.util.Objects; +import java.util.Optional; + +/** + * Whether one STOMP frame may proceed, and why not when it may not. + * + *

A value rather than an exception so the decision can be tested without a channel, and so the + * reason reaches the metric and the log without the destination going with it. + * + * @param allowed whether the frame proceeds + * @param reason why it was refused, absent when it was allowed + */ +public record StompAuthorizationDecision(boolean allowed, Optional reason) { + + public StompAuthorizationDecision { + Objects.requireNonNull(reason, "reason"); + if (allowed == reason.isPresent()) { + throw new IllegalArgumentException( + "an allowed decision carries no refusal and a refused one must say why"); + } + } + + /** The frame proceeds. */ + public static StompAuthorizationDecision allow() { + return new StompAuthorizationDecision(true, Optional.empty()); + } + + /** The frame is refused. */ + public static StompAuthorizationDecision refuse(StompRefusal reason) { + return new StompAuthorizationDecision(false, Optional.of(Objects.requireNonNull(reason))); + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/StompAuthorizationException.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/StompAuthorizationException.java new file mode 100644 index 00000000..fdbeba81 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/StompAuthorizationException.java @@ -0,0 +1,27 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.stomp; + +import java.util.Objects; + +/** + * A STOMP frame that was refused. + * + *

Carries the closed-set reason and nothing else. The destination is deliberately absent from + * the message: this exception's text reaches the client through the STOMP {@code ERROR} frame, and + * echoing the destination back confirms to a prober which destinations exist. + */ +public final class StompAuthorizationException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + private final transient StompRefusal refusal; + + public StompAuthorizationException(StompRefusal refusal) { + super("stomp frame refused"); + this.refusal = Objects.requireNonNull(refusal, "refusal"); + } + + /** Why it was refused, for the metric and the server-side log. */ + public StompRefusal refusal() { + return refusal; + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/StompAuthorizationPolicy.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/StompAuthorizationPolicy.java new file mode 100644 index 00000000..9929d29e --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/StompAuthorizationPolicy.java @@ -0,0 +1,93 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.stomp; + +import java.util.Objects; +import java.util.Optional; + +/** + * The authorization decision for an inbound STOMP frame. + * + *

Separate from the interceptor so the whole decision table can be exercised without a message + * channel. The interceptor's job is extraction and enforcement; the judgement is here. + * + *

Every path that is not an explicit allow is a refusal. That sounds obvious and is the thing + * that goes wrong: an authorization check written as a series of {@code if (bad) throw} statements + * allows anything the author did not think to test for, and the set of destinations a STOMP client + * can name is unbounded. + */ +public final class StompAuthorizationPolicy { + + private final StompDestinationCatalog catalog; + + public StompAuthorizationPolicy(StompDestinationCatalog catalog) { + this.catalog = Objects.requireNonNull(catalog, "catalog"); + } + + /** Judge one frame. */ + public StompAuthorizationDecision decide(StompFrameRequest request) { + Objects.requireNonNull(request, "request"); + Optional principal = request.principalName().filter(name -> !name.isBlank()); + if (principal.isEmpty()) { + return StompAuthorizationDecision.refuse(StompRefusal.UNAUTHENTICATED); + } + if (request.ackHeader().isPresent() + && StompAckMode.fromWire(request.ackHeader().get()).isEmpty()) { + // Refused rather than defaulted. Defaulting an unrecognised mode to auto turns a client + // asking for at-least-once into one that silently gets at-most-once. + return StompAuthorizationDecision.refuse(StompRefusal.UNSUPPORTED_ACK_MODE); + } + Optional role = catalog.profile().roleOf(request.destination()); + if (role.isEmpty()) { + return StompAuthorizationDecision.refuse(StompRefusal.UNDECLARED_DESTINATION); + } + // A user-prefixed destination is written two ways. "/user/queue/x" is self-addressed: Spring + // resolves it against the session's own principal, so it is declared literally and needs no + // ownership check. "/user/alice/queue/x" names somebody else, and is how one client reaches + // another's private queue. Only the second form needs the check, and it is distinguished by + // the literal not being in the catalog. + String resolved = request.destination(); + if (role.get() == StompDestinationRole.USER + && !catalog.declares(request.operation(), resolved)) { + Optional targeted = stripTargetName(resolved); + if (targeted.isPresent()) { + if (!targeted.get().name().equals(principal.get())) { + return StompAuthorizationDecision.refuse(StompRefusal.FOREIGN_USER_DESTINATION); + } + resolved = targeted.get().destination(); + } + } + Optional required = catalog.permissionFor(request.operation(), resolved); + if (required.isEmpty()) { + // Declared for the other operation, or not declared at all. Distinguished so an operator + // reading the metric can tell a missing declaration from a deliberate read-only feed. + String candidate = resolved; + boolean declaredElsewhere = + java.util.Arrays.stream(StompOperation.values()) + .anyMatch(other -> catalog.declares(other, candidate)); + return StompAuthorizationDecision.refuse( + declaredElsewhere + ? StompRefusal.OPERATION_NOT_PERMITTED + : StompRefusal.UNDECLARED_DESTINATION); + } + if (!request.grantedPermissions().contains(required.get())) { + return StompAuthorizationDecision.refuse(StompRefusal.MISSING_PERMISSION); + } + return StompAuthorizationDecision.allow(); + } + + /** A destination with the target name lifted out of it. */ + private record TargetedDestination(String name, String destination) {} + + private static Optional stripTargetName(String destination) { + int prefixEnd = destination.indexOf('/', 1); + if (prefixEnd < 0) { + return Optional.empty(); + } + int nameEnd = destination.indexOf('/', prefixEnd + 1); + if (nameEnd < 0 || nameEnd == prefixEnd + 1) { + return Optional.empty(); + } + String name = destination.substring(prefixEnd + 1, nameEnd); + String remainder = destination.substring(0, prefixEnd) + destination.substring(nameEnd); + return Optional.of(new TargetedDestination(name, remainder)); + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/StompBrokerExclusivity.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/StompBrokerExclusivity.java new file mode 100644 index 00000000..c74a72a8 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/StompBrokerExclusivity.java @@ -0,0 +1,74 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.stomp; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** + * Refuses a configuration in which two STOMP runtimes would both try to own the broker. + * + *

This leaf ships an older STOMP-over-SockJS channel with its own + * {@code @EnableWebSocketMessageBroker}, and the Advanced adapter is a second one. Spring will + * start both: each contributes a {@code WebSocketMessageBrokerConfigurer}, both call {@code + * configureMessageBroker}, and the resulting broker is whichever ran last. Nothing fails, nothing + * logs, and the prefixes in effect are not the ones in either configuration file. + * + *

So it is refused at startup instead. The same reasoning, and the same shape, as {@code + * WebSocketStackExclusivity}: a runtime that silently half-applies is worse than one that will not + * start. + */ +public final class StompBrokerExclusivity { + + private StompBrokerExclusivity() {} + + /** + * Fail the context rather than start an ambiguous broker. + * + * @param legacyStompEnabled whether {@code ca-skeleton.websocket.enabled} is set + * @param advancedStompEnabled whether the Advanced STOMP capability is named + * @param simpleBrokerEnabled whether the in-memory broker is configured + * @param relayEnabled whether the RabbitMQ relay is configured + */ + public static void verify( + boolean legacyStompEnabled, + boolean advancedStompEnabled, + boolean simpleBrokerEnabled, + boolean relayEnabled) { + List faults = new ArrayList<>(); + if (legacyStompEnabled && advancedStompEnabled) { + faults.add( + "both the legacy STOMP channel (ca-skeleton.websocket.enabled) and the Advanced STOMP " + + "adapter are enabled; each configures the message broker and the one that wins is " + + "decided by bean ordering"); + } + if (advancedStompEnabled && simpleBrokerEnabled && relayEnabled) { + faults.add( + "the simple broker and the RabbitMQ relay are both enabled; they are alternatives, and " + + "enabling both means subscriptions land in one of them and publishes in the other"); + } + if (advancedStompEnabled && !simpleBrokerEnabled && !relayEnabled) { + faults.add( + "the Advanced STOMP adapter is enabled with no broker behind it; every SUBSCRIBE would " + + "be accepted and nothing would ever be delivered"); + } + if (!faults.isEmpty()) { + throw new IllegalStateException( + "STOMP broker configuration is ambiguous: " + String.join("; ", faults)); + } + } + + /** The same check as a predicate, for a report that wants to show the state without failing. */ + public static boolean valid( + boolean legacyStompEnabled, + boolean advancedStompEnabled, + boolean simpleBrokerEnabled, + boolean relayEnabled) { + try { + verify(legacyStompEnabled, advancedStompEnabled, simpleBrokerEnabled, relayEnabled); + return true; + } catch (IllegalStateException refused) { + Objects.requireNonNull(refused); + return false; + } + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/StompConfiguration.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/StompConfiguration.java new file mode 100644 index 00000000..e08778c9 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/StompConfiguration.java @@ -0,0 +1,52 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.stomp; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.messaging.simp.config.ChannelRegistration; +import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; + +/** + * Applies the Advanced STOMP profile to Spring's broker registry. + * + *

Only when the capability is named and the legacy channel is not enabled. That second condition + * is not belt-and-braces: two {@code @EnableWebSocketMessageBroker} configurations in one context + * produce a broker whose prefixes are whichever configurer ran last, with no error anywhere. {@link + * StompBrokerExclusivity} still runs and still fails loudly, because a condition that quietly + * declines to start the adapter looks from the outside exactly like an adapter that started and + * does nothing. + */ +@Configuration(proxyBeanMethods = false) +@EnableWebSocketMessageBroker +@Import(StompDefaultsConfiguration.class) +@ConditionalOnProperty( + prefix = "app.websocket-platform.advanced.stomp", + name = "enabled", + havingValue = "true") +public class StompConfiguration implements WebSocketMessageBrokerConfigurer { + + private final StompProfile profile; + private final StompSecurityInterceptor securityInterceptor; + + public StompConfiguration(StompProfile profile, StompSecurityInterceptor securityInterceptor) { + this.profile = profile; + this.securityInterceptor = securityInterceptor; + } + + @Override + public void configureClientInboundChannel(ChannelRegistration registration) { + registration.interceptors(securityInterceptor); + } + + @Override + public void configureMessageBroker(MessageBrokerRegistry registry) { + registry.setApplicationDestinationPrefixes( + profile.applicationPrefixes().toArray(String[]::new)); + registry.setPreservePublishOrder(profile.preserveOrder()); + profile.userPrefixes().stream().findFirst().ifPresent(registry::setUserDestinationPrefix); + // The broker itself is contributed by whichever broker configuration is active. Enabling none + // is refused by StompBrokerExclusivity rather than silently accepted here. + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/StompDefaultsConfiguration.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/StompDefaultsConfiguration.java new file mode 100644 index 00000000..1fadbc28 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/StompDefaultsConfiguration.java @@ -0,0 +1,58 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.stomp; + +import java.security.Principal; +import java.util.Set; +import java.util.function.Function; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * The Advanced STOMP beans, separate from the configurer that consumes them. + * + *

Separate because a {@code @Configuration} class cannot take its own {@code @Bean} methods as + * constructor arguments — Spring has to build the class before it can call them, and asking for + * both is a cycle the context refuses to start with. + */ +@Configuration(proxyBeanMethods = false) +@ConditionalOnProperty( + prefix = "app.websocket-platform.advanced.stomp", + name = "enabled", + havingValue = "true") +public class StompDefaultsConfiguration { + + /** The prefix layout, overridable by a deployment that declares its own. */ + @Bean + @ConditionalOnMissingBean + public StompProfile stompProfile() { + return StompProfile.conventional(); + } + + /** + * The authorization policy. + * + *

Requires a catalog, and there is deliberately no default one. A default would have to either + * declare nothing, which refuses every frame and reads as a broken adapter, or declare something, + * which publishes destinations nobody chose. + */ + @Bean + @ConditionalOnMissingBean + public StompAuthorizationPolicy stompAuthorizationPolicy(StompDestinationCatalog catalog) { + return new StompAuthorizationPolicy(catalog); + } + + /** The interceptor, given whatever this deployment uses to read a principal's permissions. */ + @Bean + @ConditionalOnMissingBean + public StompSecurityInterceptor stompSecurityInterceptor( + StompAuthorizationPolicy policy, + ObjectProvider>> permissionReader) { + // No reader configured means no permissions, which means every declared destination is refused + // for want of one. That is the safe direction: the alternative default is "everybody holds + // everything", and it would not be noticed until it was in production. + return new StompSecurityInterceptor( + policy, permissionReader.getIfAvailable(() -> principal -> Set.of())); + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/StompDestinationCatalog.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/StompDestinationCatalog.java new file mode 100644 index 00000000..6c3788f5 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/StompDestinationCatalog.java @@ -0,0 +1,129 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.stomp; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; + +/** + * The destinations that exist, and what each one requires. + * + *

Declared rather than discovered. A STOMP destination arrives as a free string from the client, + * so without a catalog the set of reachable destinations is whatever the broker happens to accept — + * which for the simple broker is every string, and for a relay is every string the broker's own + * topology permits. Neither is a decision anybody made. + * + *

Authorization is per operation, not per destination. The same feed is usually readable by many + * and writable by few, and a catalog that carried one permission per destination would have to pick + * the looser of the two. + */ +public final class StompDestinationCatalog { + + private final StompProfile profile; + private final Map permissions; + + private StompDestinationCatalog(StompProfile profile, Map permissions) { + this.profile = profile; + this.permissions = Map.copyOf(permissions); + } + + /** A builder, because a catalog is written once at startup and read on every frame. */ + public static Builder using(StompProfile profile) { + return new Builder(Objects.requireNonNull(profile, "profile")); + } + + /** The prefix profile this catalog was built against. */ + public StompProfile profile() { + return profile; + } + + /** Every declared entry, for the startup report. */ + public List declaredDestinations() { + return permissions.keySet().stream().map(Key::destination).distinct().sorted().toList(); + } + + /** + * The permission required for one operation on one destination. + * + *

Empty means undeclared, and undeclared means refused. Returning a permissive default here + * would make every unlisted destination reachable, which is the failure this class exists to + * prevent. + */ + public Optional permissionFor(StompOperation operation, String destination) { + Objects.requireNonNull(operation, "operation"); + if (destination == null) { + return Optional.empty(); + } + return Optional.ofNullable(permissions.get(new Key(operation, destination))); + } + + /** Whether the destination is declared for the operation at all. */ + public boolean declares(StompOperation operation, String destination) { + return permissionFor(operation, destination).isPresent(); + } + + private record Key(StompOperation operation, String destination) {} + + /** Collects declarations and checks them against the profile as they are made. */ + public static final class Builder { + + private final StompProfile profile; + private final Map permissions = new LinkedHashMap<>(); + + private Builder(StompProfile profile) { + this.profile = profile; + } + + /** + * Declare a destination and the permission each listed operation needs. + * + * @param destination the exact destination string + * @param permission the permission a caller must hold + * @param operations which operations that permission covers + */ + public Builder declare(String destination, String permission, StompOperation... operations) { + Objects.requireNonNull(destination, "destination"); + Objects.requireNonNull(permission, "permission"); + if (permission.isBlank()) { + throw new IllegalArgumentException( + "a blank permission reads as 'declared' while granting everyone access"); + } + if (operations.length == 0) { + throw new IllegalArgumentException( + "a destination declared for no operation is unreachable, which is almost never what " + + "the author meant"); + } + // Checked here rather than at first use: a destination outside every prefix is dead on + // arrival, and startup is when somebody is still reading the output. + if (profile.roleOf(destination).isEmpty()) { + throw new IllegalArgumentException( + "destination matches no declared prefix, so nothing would ever route to it: " + + destination); + } + for (StompOperation operation : Set.of(operations)) { + String previous = permissions.putIfAbsent(new Key(operation, destination), permission); + if (previous != null && !previous.equals(permission)) { + throw new IllegalArgumentException( + "two permissions declared for " + + operation + + " on " + + destination + + "; the looser one would win by accident"); + } + } + return this; + } + + /** Freeze the catalog. */ + public StompDestinationCatalog build() { + if (permissions.isEmpty()) { + throw new IllegalArgumentException( + "an empty catalog refuses every frame, which is a misconfiguration rather than a " + + "lockdown"); + } + return new StompDestinationCatalog(profile, permissions); + } + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/StompDestinationRole.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/StompDestinationRole.java new file mode 100644 index 00000000..7f668c6f --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/StompDestinationRole.java @@ -0,0 +1,14 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.stomp; + +/** What a matched destination prefix means for routing. */ +public enum StompDestinationRole { + + /** Routed into application code. */ + APPLICATION, + + /** Routed to the broker, with no application code in the path. */ + BROKER, + + /** Resolved to one session's private destination before routing. */ + USER +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/StompEvidence.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/StompEvidence.java new file mode 100644 index 00000000..b3630b24 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/StompEvidence.java @@ -0,0 +1,71 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.stomp; + +/** + * What a STOMP acknowledgement actually proves. + * + *

Six stages because STOMP hands out three different signals that all look like success, and + * conflating any two of them produces a system that reports work as done when it is not. The order + * of the constants is the order the stages occur in; nothing else about it is meaningful, and the + * {@link #rank()} below exists so no caller reaches for the ordinal. + * + *

The specific confusion this exists to prevent: a STOMP {@code RECEIPT} frame means the server + * read the frame. It does not mean a handler ran, a transaction committed, or a broker stored + * anything. A client that treats a receipt as a commit will show a user their order was placed + * while the transaction that would have placed it is still open — or already rolled back. + */ +public enum StompEvidence { + + /** The server read the frame off the socket. Proves transport, nothing else. */ + FRAME_RECEIVED(0), + + /** + * A STOMP {@code RECEIPT} frame was sent. + * + *

A protocol-level acknowledgement of receipt. Explicitly not a commit: the receipt is written + * by the protocol layer, which knows nothing about whether the work succeeded. + */ + PROTOCOL_RECEIPT(1), + + /** The broker accepted the message for delivery. */ + BROKER_DELIVERY(2), + + /** + * The broker acknowledged it. + * + *

Durable only if the broker is durable. Against the simple in-memory broker this stage is + * reached and still survives nothing, which is why the broker profile carries {@code + * durableAckSupported} rather than this enum implying it. + */ + BROKER_ACK(3), + + /** Application code ran and its transaction committed. The first stage that means "done". */ + APPLICATION_COMMIT(4), + + /** The client sent an {@code ACK} frame saying it applied the message. */ + CLIENT_APPLIED(5); + + private final int rank; + + StompEvidence(int rank) { + this.rank = rank; + } + + /** How far along the chain this stage sits. Declared, so no caller depends on the ordinal. */ + public int rank() { + return rank; + } + + /** Whether this stage means the work itself is done rather than merely observed. */ + public boolean provesCompletion() { + return this == APPLICATION_COMMIT || this == CLIENT_APPLIED; + } + + /** + * Whether reporting {@code claimed} on the strength of {@code this} would overstate the case. + * + * @param claimed the stage a caller wants to report + */ + public boolean wouldOverstate(StompEvidence claimed) { + return claimed.rank() > rank; + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/StompFrameRequest.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/StompFrameRequest.java new file mode 100644 index 00000000..a8cf7d28 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/StompFrameRequest.java @@ -0,0 +1,35 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.stomp; + +import java.util.Objects; +import java.util.Optional; +import java.util.Set; + +/** + * One inbound STOMP frame, reduced to what the authorization decision needs. + * + *

Deliberately not the Spring message. The policy is the part worth testing exhaustively, and + * making it depend on a {@code Message} would mean every case needed a channel and an accessor to + * express. The interceptor does the extraction; this carries the result. + * + * @param operation what the frame is trying to do + * @param destination the destination as the client wrote it + * @param principalName the authenticated principal, absent when there is none + * @param grantedPermissions what the principal holds + * @param ackHeader the raw {@code ack} header, absent when unset + */ +public record StompFrameRequest( + StompOperation operation, + String destination, + Optional principalName, + Set grantedPermissions, + Optional ackHeader) { + + public StompFrameRequest { + Objects.requireNonNull(operation, "operation"); + Objects.requireNonNull(destination, "destination"); + Objects.requireNonNull(principalName, "principalName"); + Objects.requireNonNull(ackHeader, "ackHeader"); + grantedPermissions = + Set.copyOf(Objects.requireNonNull(grantedPermissions, "grantedPermissions")); + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/StompOperation.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/StompOperation.java new file mode 100644 index 00000000..b84c563b --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/StompOperation.java @@ -0,0 +1,17 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.stomp; + +/** + * The STOMP frame commands the adapter authorizes. + * + *

{@link #SUBSCRIBE} and {@link #SEND} are separate because they are separate permissions. + * Reading a destination and writing to it are different rights, and a catalog that authorized them + * together would let anyone who may watch an order feed also publish into it. + */ +public enum StompOperation { + + /** Establishing a subscription: the right to read a destination. */ + SUBSCRIBE, + + /** Sending a frame: the right to write to a destination. */ + SEND +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/StompProfile.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/StompProfile.java new file mode 100644 index 00000000..c0efc6e4 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/StompProfile.java @@ -0,0 +1,118 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.stomp; + +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +/** + * Which destination prefixes mean what. + * + *

Three separate prefix sets rather than one, because the three have different security + * consequences and merging them is how a client subscribes to a destination that was meant to be + * server-only. An application prefix routes a frame into application code; a broker prefix routes + * it to whatever broker is configured, with no application code in the path; a user prefix is + * resolved per session before either happens. A destination that matched two of them would be + * dispatched by whichever check ran first. + * + *

A destination string is not a reliability guarantee. {@code /topic/orders} says where a + * message goes, not that it arrives, is ordered, or survives a broker restart — those come from the + * broker profile, and the simple broker offers none of them. + * + * @param applicationPrefixes prefixes routed into {@code @MessageMapping} handlers + * @param brokerPrefixes prefixes routed straight to the broker + * @param userPrefixes prefixes resolved to a single session before routing + * @param preserveOrder whether to preserve publication order per session + */ +public record StompProfile( + Set applicationPrefixes, + Set brokerPrefixes, + Set userPrefixes, + boolean preserveOrder) { + + public StompProfile { + applicationPrefixes = normalize(applicationPrefixes, "applicationPrefixes"); + brokerPrefixes = normalize(brokerPrefixes, "brokerPrefixes"); + userPrefixes = normalize(userPrefixes, "userPrefixes"); + if (applicationPrefixes.isEmpty() && brokerPrefixes.isEmpty()) { + throw new IllegalArgumentException( + "a STOMP profile with neither an application nor a broker prefix routes nothing"); + } + rejectOverlap(applicationPrefixes, brokerPrefixes, "application", "broker"); + rejectOverlap(applicationPrefixes, userPrefixes, "application", "user"); + rejectOverlap(brokerPrefixes, userPrefixes, "broker", "user"); + } + + /** + * The conventional layout: {@code /app} into handlers, {@code /topic} and {@code /queue} to the + * broker, {@code /user} resolved per session. + */ + public static StompProfile conventional() { + return new StompProfile(Set.of("/app"), Set.of("/topic", "/queue"), Set.of("/user"), true); + } + + /** + * Which of the three roles a destination has, if any. + * + *

Returns empty for anything unmatched, and the caller must treat that as a refusal rather + * than a default. A STOMP client sends the destination as a free string, so "unmatched" is the + * normal shape of both a typo and an attempt to reach somewhere that was never published. + */ + public java.util.Optional roleOf(String destination) { + if (destination == null || destination.isBlank()) { + return java.util.Optional.empty(); + } + if (matchesAny(userPrefixes, destination)) { + return java.util.Optional.of(StompDestinationRole.USER); + } + if (matchesAny(applicationPrefixes, destination)) { + return java.util.Optional.of(StompDestinationRole.APPLICATION); + } + if (matchesAny(brokerPrefixes, destination)) { + return java.util.Optional.of(StompDestinationRole.BROKER); + } + return java.util.Optional.empty(); + } + + private static boolean matchesAny(Set prefixes, String destination) { + // Prefix plus a separator, not bare startsWith: "/topicprivate" is not under "/topic", and + // treating it as though it were hands the caller a destination the operator never published. + return prefixes.stream() + .anyMatch(prefix -> destination.equals(prefix) || destination.startsWith(prefix + "/")); + } + + private static Set normalize(Set prefixes, String field) { + Objects.requireNonNull(prefixes, field); + Set normalized = new LinkedHashSet<>(); + for (String prefix : prefixes) { + Objects.requireNonNull(prefix, field); + if (!prefix.startsWith("/") || prefix.endsWith("/") || prefix.contains("//")) { + throw new IllegalArgumentException( + "a STOMP prefix must be an absolute normalized path without a trailing slash: " + + prefix); + } + normalized.add(prefix); + } + return Set.copyOf(normalized); + } + + private static void rejectOverlap(Set left, Set right, String a, String b) { + List clashes = + left.stream() + .filter(prefix -> right.stream().anyMatch(other -> nested(prefix, other))) + .toList(); + if (!clashes.isEmpty()) { + throw new IllegalArgumentException( + "a destination cannot be both " + + a + + " and " + + b + + "; whichever check ran first would decide where it went: " + + clashes); + } + } + + private static boolean nested(String one, String other) { + return one.equals(other) || one.startsWith(other + "/") || other.startsWith(one + "/"); + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/StompRefusal.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/StompRefusal.java new file mode 100644 index 00000000..7b68f2d6 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/StompRefusal.java @@ -0,0 +1,29 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.stomp; + +/** + * Why a STOMP frame was refused. + * + *

A closed set, because this is what reaches the metric tag. The destination cannot: it is + * client-supplied and unbounded, and a metric tagged with it turns any client into a way to exhaust + * the metrics backend's cardinality budget. + */ +public enum StompRefusal { + + /** The frame arrived before the connection was authenticated. */ + UNAUTHENTICATED, + + /** The destination is not in the catalog. Undeclared is refused, never allowed by default. */ + UNDECLARED_DESTINATION, + + /** The destination is declared, but not for this operation. */ + OPERATION_NOT_PERMITTED, + + /** The caller is authenticated but lacks the declared permission. */ + MISSING_PERMISSION, + + /** The frame's {@code ack} header named a mode the server does not implement. */ + UNSUPPORTED_ACK_MODE, + + /** A user-prefixed destination naming somebody other than the caller. */ + FOREIGN_USER_DESTINATION +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/StompSecurityInterceptor.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/StompSecurityInterceptor.java new file mode 100644 index 00000000..843a0aa7 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/StompSecurityInterceptor.java @@ -0,0 +1,96 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.stomp; + +import java.security.Principal; +import java.util.Collection; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.function.Function; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.simp.stomp.StompCommand; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.messaging.support.ChannelInterceptor; +import org.springframework.messaging.support.MessageHeaderAccessor; + +/** + * Applies {@link StompAuthorizationPolicy} to the client inbound channel. + * + *

This class is extraction and enforcement only; every judgement lives in the policy. The split + * is not tidiness — an authorization rule that can only be exercised through a {@code + * MessageChannel} gets tested for the two cases somebody bothered to build a channel for, and the + * ones nobody built are the ones that let a frame through. + * + *

It refuses by throwing, because on this channel that is the only thing a client notices. + * Returning {@code null} from {@code preSend} drops the message silently, and a client whose + * SUBSCRIBE was silently dropped waits forever for messages that will never come, with nothing in + * either log explaining why. + */ +public final class StompSecurityInterceptor implements ChannelInterceptor { + + private final StompAuthorizationPolicy policy; + private final Function> permissionsOf; + + /** + * @param policy the decision table + * @param permissionsOf how to read a principal's permissions; supplied rather than assumed so + * this leaf does not acquire a Spring Security dependency for one call + */ + public StompSecurityInterceptor( + StompAuthorizationPolicy policy, Function> permissionsOf) { + this.policy = Objects.requireNonNull(policy, "policy"); + this.permissionsOf = Objects.requireNonNull(permissionsOf, "permissionsOf"); + } + + @Override + public Message preSend(Message message, MessageChannel channel) { + StompHeaderAccessor accessor = + MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class); + if (accessor == null || accessor.isHeartbeat() || accessor.getCommand() == null) { + return message; + } + Optional operation = operationFor(accessor.getCommand()); + if (operation.isEmpty()) { + // CONNECT, DISCONNECT, ACK, NACK and the transaction frames carry no destination, so there + // is nothing for this policy to authorize. They are not thereby unguarded: the handshake + // authenticates, and ACK/NACK are checked against the subscription that issued them. + return message; + } + String destination = accessor.getDestination(); + if (destination == null) { + throw new StompAuthorizationException(StompRefusal.UNDECLARED_DESTINATION); + } + Principal user = accessor.getUser(); + StompFrameRequest request = + new StompFrameRequest( + operation.get(), + destination, + Optional.ofNullable(user).map(Principal::getName), + user == null ? Set.of() : permissionsOf.apply(user), + firstHeader(accessor, "ack")); + StompAuthorizationDecision decision = policy.decide(request); + if (!decision.allowed()) { + throw new StompAuthorizationException(decision.reason().orElseThrow()); + } + return message; + } + + private static Optional operationFor(StompCommand command) { + if (command == StompCommand.SUBSCRIBE) { + return Optional.of(StompOperation.SUBSCRIBE); + } + if (command == StompCommand.SEND) { + return Optional.of(StompOperation.SEND); + } + return Optional.empty(); + } + + private static Optional firstHeader(StompHeaderAccessor accessor, String name) { + Collection values = accessor.getNativeHeader(name); + if (values == null || values.isEmpty()) { + return Optional.empty(); + } + return Optional.ofNullable(List.copyOf(values).get(0)); + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/rabbit/MultiNodeUserDestination.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/rabbit/MultiNodeUserDestination.java new file mode 100644 index 00000000..1e1f64ba --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/rabbit/MultiNodeUserDestination.java @@ -0,0 +1,98 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.stomp.rabbit; + +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketNodeId; +import java.time.Instant; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Resolves a user destination to a node, and decides what to do when it cannot. + * + *

Three outcomes, and the third is the one that exists to stop a loop. A message for a locally + * held user is delivered here. A message for a user held elsewhere, or not found, is broadcast once + * so the holding node can claim it. A message that arrived *because* of such a broadcast and still + * cannot be resolved is sent to the unresolved destination and never broadcast again — otherwise + * every node rebroadcasts it on receipt, and the cluster spends its entire broker budget passing + * one undeliverable message around. + * + *

The registry is a cache. Entries expire, and an expired entry is treated as absent rather than + * as "not connected": the difference is that absent still broadcasts, while "not connected" would + * drop the message for a user who is connected to a node that merely stopped refreshing. + */ +public final class MultiNodeUserDestination { + + private final UserDestinationPolicy policy; + private final WebSocketNodeId localNode; + private final Map registry = new ConcurrentHashMap<>(); + + public MultiNodeUserDestination(UserDestinationPolicy policy, WebSocketNodeId localNode) { + this.policy = Objects.requireNonNull(policy, "policy"); + this.localNode = Objects.requireNonNull(localNode, "localNode"); + } + + /** Record that this node holds a session for the user. */ + public void register(String userFingerprint, Instant at) { + Objects.requireNonNull(userFingerprint, "userFingerprint"); + registry.put(userFingerprint, new UserSessionLocation(localNode, Objects.requireNonNull(at))); + } + + /** Record that some other node reported holding the user. */ + public void observe(String userFingerprint, WebSocketNodeId node, Instant at) { + Objects.requireNonNull(userFingerprint, "userFingerprint"); + registry.put(userFingerprint, new UserSessionLocation(node, Objects.requireNonNull(at))); + } + + /** + * Forget a session. + * + *

Called on disconnect, and this is also where the broker's temporary queue is released. A + * relay creates one per user destination subscription; without this the queues accumulate for + * every session that ever connected, and RabbitMQ's memory alarm fires long before anybody + * connects the two facts. + */ + public boolean deregister(String userFingerprint) { + return registry.remove(userFingerprint) != null; + } + + /** Drop entries no node has refreshed within the policy's TTL. Returns how many went. */ + public int evictExpired(Instant now) { + Objects.requireNonNull(now, "now"); + int before = registry.size(); + registry.values().removeIf(location -> expired(location, now)); + return before - registry.size(); + } + + /** How many mappings are held. */ + public int trackedUsers() { + return registry.size(); + } + + /** + * Decide how to deliver. + * + * @param userFingerprint whose message this is + * @param alreadyBroadcast whether this message arrived via the cluster broadcast + * @param now the current instant + */ + public UserDestinationRouting route( + String userFingerprint, boolean alreadyBroadcast, Instant now) { + Objects.requireNonNull(userFingerprint, "userFingerprint"); + Objects.requireNonNull(now, "now"); + Optional location = + Optional.ofNullable(registry.get(userFingerprint)).filter(entry -> !expired(entry, now)); + if (location.isPresent() && location.get().nodeId().equals(localNode)) { + return UserDestinationRouting.deliverLocally(); + } + if (alreadyBroadcast) { + // The message is already the result of a broadcast. Broadcasting it again is the loop. + return UserDestinationRouting.sendToUnresolved(policy.unresolvedDestination()); + } + return UserDestinationRouting.broadcast(policy.broadcastDestination()); + } + + private boolean expired(UserSessionLocation location, Instant now) { + return !now.isBefore(location.registeredAt().plus(policy.registryTtl())); + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/rabbit/RabbitBrokerRelayConfiguration.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/rabbit/RabbitBrokerRelayConfiguration.java new file mode 100644 index 00000000..9422530a --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/rabbit/RabbitBrokerRelayConfiguration.java @@ -0,0 +1,72 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.stomp.rabbit; + +import dev.caskeleton.adapter.inbound.websocket.advanced.stomp.StompProfile; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketNodeId; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; + +/** + * Points the broker at RabbitMQ instead of the in-memory one. + * + *

Contributes only the broker, not the prefixes: those belong to {@link StompProfile} and are + * applied by the STOMP configuration. Two configurers each setting the application prefix is the + * failure this arrangement avoids. + * + *

Enabling this makes delivery depend on a separate machine over a network. The heartbeat is + * configured from the profile rather than defaulted, because the default is the thing that decides + * whether a half-open connection to a dead broker is noticed in seconds or never. + */ +@Configuration(proxyBeanMethods = false) +@ConditionalOnProperty( + prefix = "app.websocket-platform.advanced.stomp.relay", + name = "enabled", + havingValue = "true") +public class RabbitBrokerRelayConfiguration implements WebSocketMessageBrokerConfigurer { + + private final StompProfile profile; + private final RabbitBrokerRelayProfile relay; + + public RabbitBrokerRelayConfiguration(StompProfile profile, RabbitBrokerRelayProfile relay) { + this.profile = profile; + this.relay = relay; + } + + /** The relay target, overridable by a deployment that declares its own. */ + @Bean + @ConditionalOnMissingBean + public static RabbitBrokerRelayProfile rabbitBrokerRelayProfile() { + // static, so the bean is available before this configuration class is constructed - the same + // cycle StompDefaultsConfiguration exists to avoid, here for a single bean. + return RabbitBrokerRelayProfile.localPlaintext(); + } + + /** How a user destination is resolved across nodes. */ + @Bean + @ConditionalOnMissingBean + public UserDestinationPolicy userDestinationPolicy() { + return UserDestinationPolicy.conventional(); + } + + /** The per-node routing table for user destinations. */ + @Bean + @ConditionalOnMissingBean + public MultiNodeUserDestination multiNodeUserDestination( + UserDestinationPolicy policy, WebSocketNodeId nodeId) { + return new MultiNodeUserDestination(policy, nodeId); + } + + @Override + public void configureMessageBroker(MessageBrokerRegistry registry) { + long heartbeat = relay.systemHeartbeat().toMillis(); + registry + .enableStompBrokerRelay(profile.brokerPrefixes().toArray(String[]::new)) + .setRelayHost(relay.host()) + .setRelayPort(relay.port()) + .setSystemHeartbeatSendInterval(heartbeat) + .setSystemHeartbeatReceiveInterval(heartbeat); + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/rabbit/RabbitBrokerRelayProfile.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/rabbit/RabbitBrokerRelayProfile.java new file mode 100644 index 00000000..ca2c2fef --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/rabbit/RabbitBrokerRelayProfile.java @@ -0,0 +1,78 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.stomp.rabbit; + +import java.time.Duration; +import java.util.List; +import java.util.Objects; + +/** + * Where the external STOMP broker is and how the relay talks to it. + * + *

The relay changes the failure model rather than adding a feature. With the simple broker, + * delivery fails when this JVM fails; with a relay, delivery fails when a separate machine fails, + * over a network, and the platform's own health check will keep reporting green throughout. That is + * why {@code systemHeartbeat} is part of the profile and not a default: it is the only thing that + * notices a relay connection that is open at the TCP level and dead above it. + * + * @param host the broker's host + * @param port its STOMP port + * @param tls whether the relay connection is encrypted + * @param systemHeartbeat the interval the relay's own connection heartbeats at + */ +public record RabbitBrokerRelayProfile( + String host, int port, boolean tls, Duration systemHeartbeat) { + + public RabbitBrokerRelayProfile { + Objects.requireNonNull(host, "host"); + Objects.requireNonNull(systemHeartbeat, "systemHeartbeat"); + if (host.isBlank()) { + throw new IllegalArgumentException( + "a blank broker host resolves to something, and it is " + + "never the broker that was meant"); + } + if (port < 1 || port > 65535) { + throw new IllegalArgumentException("not a port: " + port); + } + if (systemHeartbeat.isNegative() || systemHeartbeat.isZero()) { + throw new IllegalArgumentException( + "a relay without a heartbeat cannot tell a quiet broker from a dead one, and a " + + "half-open TCP connection to a dead broker accepts every publish silently"); + } + } + + /** The conventional local development relay: plaintext, on the RabbitMQ STOMP port. */ + public static RabbitBrokerRelayProfile localPlaintext() { + return new RabbitBrokerRelayProfile("localhost", 61613, false, Duration.ofSeconds(10)); + } + + /** + * What this profile is missing before it is fit for anything but a developer's machine. + * + *

Reported rather than enforced here, because "is this deployment allowed to be plaintext" is + * a question the startup validator answers with the active profiles in hand. + */ + public List productionConcerns() { + if (tls) { + return List.of(); + } + return List.of( + "the relay carries every message and the broker credentials in clear text; anything on " + + "the path between this node and the broker reads both"); + } + + /** + * How many broker connections a client population costs. + * + *

Worth computing before the first outage rather than after. The relay opens one shared system + * connection plus one per authenticated user session, so a broker with a connection limit turns a + * normal-looking client count into a hard ceiling — and the symptom is new users failing to + * subscribe while existing ones are fine. + * + * @param authenticatedSessions how many user sessions are connected + */ + public int brokerConnectionsFor(int authenticatedSessions) { + if (authenticatedSessions < 0) { + throw new IllegalArgumentException("a session count cannot be negative"); + } + return authenticatedSessions + 1; + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/rabbit/UserDestinationAction.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/rabbit/UserDestinationAction.java new file mode 100644 index 00000000..59c9a8eb --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/rabbit/UserDestinationAction.java @@ -0,0 +1,21 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.stomp.rabbit; + +/** + * The three dispositions of a user-addressed message. + * + *

A closed set, and the metric is tagged with this rather than with the destination. A user + * destination contains a user identifier by construction, so tagging a metric with it publishes + * every user's name into the metrics backend and gives the cardinality budget an unbounded + * denominator. + */ +public enum UserDestinationAction { + + /** The session is on this node. */ + DELIVER_LOCALLY, + + /** Published to the cluster so the holding node can claim it. */ + BROADCAST, + + /** Already broadcast once and still unclaimed. Terminal, so the loop ends. */ + UNRESOLVED +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/rabbit/UserDestinationPolicy.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/rabbit/UserDestinationPolicy.java new file mode 100644 index 00000000..f092cb56 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/rabbit/UserDestinationPolicy.java @@ -0,0 +1,50 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.stomp.rabbit; + +import java.time.Duration; +import java.util.Objects; + +/** + * How a message for a user who is connected to some other node gets there. + * + *

The mechanism is a broadcast: a node that cannot resolve a user locally publishes to a + * cluster-wide destination, and whichever node holds that session picks it up. Two things about it + * are easy to get wrong, and both are why this is a declared policy rather than three string + * constants. + * + *

First, the broadcast destination and the unresolved destination must differ. If a node + * republished unresolved messages onto the same destination it listens to, every node would + * re-broadcast every unresolvable message forever — a loop that saturates the broker and looks, + * from any single node, like ordinary traffic. + * + *

Second, the registry entry has to expire. It maps a user to a node, the node can vanish + * without unregistering, and an entry that never expires routes that user's messages at a machine + * that is gone. + * + * @param broadcastDestination where a node publishes messages for users it does not hold + * @param unresolvedDestination where messages nobody claimed are sent + * @param registryTtl how long a user-to-node mapping is trusted + */ +public record UserDestinationPolicy( + String broadcastDestination, String unresolvedDestination, Duration registryTtl) { + + public UserDestinationPolicy { + Objects.requireNonNull(broadcastDestination, "broadcastDestination"); + Objects.requireNonNull(unresolvedDestination, "unresolvedDestination"); + Objects.requireNonNull(registryTtl, "registryTtl"); + if (broadcastDestination.equals(unresolvedDestination)) { + throw new IllegalArgumentException( + "the broadcast and unresolved destinations must differ, or every node re-broadcasts " + + "every unresolvable message forever"); + } + if (registryTtl.isNegative() || registryTtl.isZero()) { + throw new IllegalArgumentException( + "a user-to-node mapping that never expires keeps routing at nodes that no longer exist"); + } + } + + /** The conventional layout. */ + public static UserDestinationPolicy conventional() { + return new UserDestinationPolicy( + "/topic/unresolved-user", "/topic/unresolved-user-dlq", Duration.ofMinutes(2)); + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/rabbit/UserDestinationRouting.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/rabbit/UserDestinationRouting.java new file mode 100644 index 00000000..88dd3328 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/rabbit/UserDestinationRouting.java @@ -0,0 +1,42 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.stomp.rabbit; + +import java.util.Objects; +import java.util.Optional; + +/** + * What to do with a message addressed to a user. + * + *

The destination is carried on the routing rather than looked up again by the caller, so the + * decision and the address it implies cannot drift apart. + * + * @param action the disposition + * @param destination where to publish, absent for local delivery + */ +public record UserDestinationRouting(UserDestinationAction action, Optional destination) { + + public UserDestinationRouting { + Objects.requireNonNull(action, "action"); + Objects.requireNonNull(destination, "destination"); + if (action == UserDestinationAction.DELIVER_LOCALLY != destination.isEmpty()) { + throw new IllegalArgumentException( + "local delivery names no destination and a publish must name one"); + } + } + + /** The session is here. */ + public static UserDestinationRouting deliverLocally() { + return new UserDestinationRouting(UserDestinationAction.DELIVER_LOCALLY, Optional.empty()); + } + + /** Ask the cluster who holds it. */ + public static UserDestinationRouting broadcast(String destination) { + return new UserDestinationRouting( + UserDestinationAction.BROADCAST, Optional.of(Objects.requireNonNull(destination))); + } + + /** Nobody claimed it and it has already been round the cluster once. */ + public static UserDestinationRouting sendToUnresolved(String destination) { + return new UserDestinationRouting( + UserDestinationAction.UNRESOLVED, Optional.of(Objects.requireNonNull(destination))); + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/rabbit/UserSessionLocation.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/rabbit/UserSessionLocation.java new file mode 100644 index 00000000..588d5e40 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/rabbit/UserSessionLocation.java @@ -0,0 +1,22 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.stomp.rabbit; + +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketNodeId; +import java.time.Instant; +import java.util.Objects; + +/** + * Where a user was last seen connected. + * + *

A cache of a fact owned by another node, so it carries when it was written and is never read + * without that being checked. + * + * @param nodeId the node that reported holding the session + * @param registeredAt when it reported + */ +public record UserSessionLocation(WebSocketNodeId nodeId, Instant registeredAt) { + + public UserSessionLocation { + Objects.requireNonNull(nodeId, "nodeId"); + Objects.requireNonNull(registeredAt, "registeredAt"); + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/authz/MessageAuthorizationPolicy.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/authz/MessageAuthorizationPolicy.java new file mode 100644 index 00000000..0fc1be11 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/authz/MessageAuthorizationPolicy.java @@ -0,0 +1,93 @@ +package dev.caskeleton.adapter.inbound.websocket.authz; + +import dev.caskeleton.adapter.inbound.websocket.protocol.WebSocketMessageType; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** + * What a caller must hold to send a particular message type. + * + *

Per message, not per connection, and the difference is the whole point. A connection is + * authorised once at handshake and then lives for hours: authorising there means the caller keeps + * whatever it had when it connected, through every role change and every revocation, until it + * happens to reconnect. On a connection that survives a deploy that is a permission that outlives + * the decision to remove it. + * + *

Per-message authorization also lets one endpoint carry types of different sensitivity, which + * is what an application actually wants — a feed and the commands that act on it are naturally the + * same connection. + * + *

Fail-closed: a type with no declared requirement is refused rather than allowed. A new message + * type is added by someone thinking about the message, not about the permission, and defaulting to + * allow means the omission ships. + */ +public final class MessageAuthorizationPolicy { + + private final Map> requirements; + + private MessageAuthorizationPolicy(Map> requirements) { + this.requirements = requirements; + } + + /** + * A policy over the declared requirements. + * + * @param requirements the authorities each type demands; an empty set means authenticated-only + */ + public static MessageAuthorizationPolicy of(Map> requirements) { + Objects.requireNonNull(requirements, "requirements"); + Map> copy = new LinkedHashMap<>(); + requirements.forEach( + (type, authorities) -> { + Objects.requireNonNull(type, "type"); + Objects.requireNonNull(authorities, "authorities"); + copy.put(type, Set.copyOf(authorities)); + }); + return new MessageAuthorizationPolicy(Map.copyOf(copy)); + } + + /** + * Whether a caller may send a type. + * + * @param type the message type + * @param heldAuthorities what the caller holds right now + */ + public boolean permits(WebSocketMessageType type, Set heldAuthorities) { + Objects.requireNonNull(type, "type"); + Objects.requireNonNull(heldAuthorities, "heldAuthorities"); + Set required = requirements.get(type); + if (required == null) { + // Fail closed. A type nobody declared a requirement for is a type nobody thought about the + // permission for, and defaulting to allow ships the omission. + return false; + } + return heldAuthorities.containsAll(required); + } + + /** Whether a requirement is declared for a type at all. */ + public boolean declares(WebSocketMessageType type) { + return requirements.containsKey(type); + } + + /** + * The types that are published but have no declared requirement. + * + *

Read by the startup validator. Every one of them is currently unsendable, which is safe and + * is not what anybody intended — so the deployment is told rather than left to discover it from a + * client that cannot do anything. + */ + public java.util.List undeclaredAmong( + java.util.Collection publishedTypes) { + return publishedTypes.stream() + .filter(type -> !declares(type)) + .sorted(java.util.Comparator.comparing(WebSocketMessageType::value)) + .toList(); + } + + /** Every declared requirement. */ + public Map> requirements() { + return requirements; + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/budget/WebSocketConnectionBudget.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/budget/WebSocketConnectionBudget.java new file mode 100644 index 00000000..2cbcc45c --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/budget/WebSocketConnectionBudget.java @@ -0,0 +1,103 @@ +package dev.caskeleton.adapter.inbound.websocket.budget; + +import java.time.Duration; +import java.util.Objects; + +/** + * What one connection is allowed to consume. + * + *

Every bound here exists because a WebSocket removes a limit that HTTP provided for free. An + * HTTP request has a finite body, one response, and a lifetime measured in seconds; a connection + * has none of those. Whatever the platform does not bound explicitly is unbounded. + * + *

{@code maxBufferedOutboundBytes} is the one that is easiest to omit and worst to lack. A slow + * consumer does not fail — it simply reads more slowly than the server writes, and the difference + * accumulates in the server's heap. One such client is invisible; a hundred is an OutOfMemoryError + * with no failing request anywhere to point at. + * + * @param maxFrameBytes the largest single frame accepted + * @param maxMessageBytes the largest reassembled message accepted + * @param maxFragments how many continuation frames one message may span + * @param maxInboundMessagesPerSecond the inbound message rate one connection may sustain + * @param maxBufferedOutboundBytes how much unsent outbound data may accumulate per connection + * @param maxConnectionAge how long any connection may live + * @param idleTimeout how long a silent connection is kept + */ +public record WebSocketConnectionBudget( + int maxFrameBytes, + int maxMessageBytes, + int maxFragments, + int maxInboundMessagesPerSecond, + long maxBufferedOutboundBytes, + Duration maxConnectionAge, + Duration idleTimeout) { + + /** The largest frame any profile may accept, whatever an endpoint asks for. */ + public static final int ABSOLUTE_FRAME_MAX = 1024 * 1024; + + /** The largest reassembled message any profile may accept. */ + public static final int ABSOLUTE_MESSAGE_MAX = 8 * 1024 * 1024; + + /** The most outbound data any profile may buffer for one connection. */ + public static final long ABSOLUTE_BUFFERED_OUTBOUND_MAX = 4L * 1024 * 1024; + + public WebSocketConnectionBudget { + Objects.requireNonNull(maxConnectionAge, "maxConnectionAge"); + Objects.requireNonNull(idleTimeout, "idleTimeout"); + requirePositive(maxFrameBytes, "maxFrameBytes"); + requirePositive(maxMessageBytes, "maxMessageBytes"); + requirePositive(maxFragments, "maxFragments"); + requirePositive(maxInboundMessagesPerSecond, "maxInboundMessagesPerSecond"); + if (maxBufferedOutboundBytes <= 0) { + throw new IllegalArgumentException("maxBufferedOutboundBytes must be positive"); + } + if (maxFrameBytes > ABSOLUTE_FRAME_MAX) { + throw new IllegalArgumentException( + "maxFrameBytes exceeds the platform ceiling of " + ABSOLUTE_FRAME_MAX); + } + if (maxMessageBytes > ABSOLUTE_MESSAGE_MAX) { + throw new IllegalArgumentException( + "maxMessageBytes exceeds the platform ceiling of " + ABSOLUTE_MESSAGE_MAX); + } + if (maxBufferedOutboundBytes > ABSOLUTE_BUFFERED_OUTBOUND_MAX) { + throw new IllegalArgumentException( + "maxBufferedOutboundBytes exceeds the platform ceiling of " + + ABSOLUTE_BUFFERED_OUTBOUND_MAX + + "; a per-connection buffer multiplied by the connection count is the heap"); + } + if (maxMessageBytes < maxFrameBytes) { + // A message bound below the frame bound admits a frame it must then reject on reassembly, + // which spends the memory the frame bound was supposed to save. + throw new IllegalArgumentException( + "maxMessageBytes below maxFrameBytes accepts a frame it will then refuse"); + } + if ((long) maxFrameBytes * maxFragments < maxMessageBytes) { + // The three bounds have to be able to describe the same message, or the largest message the + // profile claims to accept cannot actually arrive. + throw new IllegalArgumentException( + "maxFrameBytes x maxFragments cannot carry maxMessageBytes, so the largest message this" + + " profile claims to accept can never be delivered"); + } + if (maxConnectionAge.isZero() || maxConnectionAge.isNegative()) { + throw new IllegalArgumentException("a connection with no maximum age is unbounded"); + } + if (idleTimeout.isZero() || idleTimeout.isNegative()) { + throw new IllegalArgumentException("a connection with no idle timeout is never reclaimed"); + } + if (idleTimeout.compareTo(maxConnectionAge) > 0) { + throw new IllegalArgumentException("an idle timeout beyond the maximum age never fires"); + } + } + + /** The platform default: modest frames, a short idle window, a four-hour ceiling. */ + public static WebSocketConnectionBudget standard() { + return new WebSocketConnectionBudget( + 64 * 1024, 512 * 1024, 16, 100, 1024L * 1024, Duration.ofHours(4), Duration.ofSeconds(90)); + } + + private static void requirePositive(int value, String name) { + if (value <= 0) { + throw new IllegalArgumentException(name + " must be positive, was " + value); + } + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/codec/StrictWebSocketJsonCodec.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/codec/StrictWebSocketJsonCodec.java new file mode 100644 index 00000000..67c8e322 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/codec/StrictWebSocketJsonCodec.java @@ -0,0 +1,145 @@ +package dev.caskeleton.adapter.inbound.websocket.codec; + +import dev.caskeleton.adapter.inbound.websocket.budget.WebSocketConnectionBudget; +import dev.caskeleton.adapter.inbound.websocket.error.WebSocketFailureCategory; +import dev.caskeleton.adapter.inbound.websocket.protocol.WebSocketMessageCatalog; +import dev.caskeleton.adapter.inbound.websocket.protocol.WebSocketMessageDescriptor; +import dev.caskeleton.adapter.inbound.websocket.protocol.WebSocketMessageFamily; +import dev.caskeleton.adapter.inbound.websocket.protocol.WebSocketMessageType; +import java.util.Objects; +import tools.jackson.core.StreamReadConstraints; +import tools.jackson.core.StreamReadFeature; +import tools.jackson.core.json.JsonFactory; +import tools.jackson.databind.DeserializationFeature; +import tools.jackson.databind.MapperFeature; +import tools.jackson.databind.json.JsonMapper; + +/** + * Decodes a wire payload into a declared record, and refuses everything else. + * + *

Strict in the specific ways that matter for a socket rather than a request. Two of them are + * not defaults anywhere: + * + *

    + *
  • Duplicate keys are rejected. Every JSON parser accepts them and silently keeps one — + * usually the last. A document carrying {@code "amount"} twice therefore means different + * things to a validating proxy and to this server, which is how a request passes review at + * one layer and executes as something else at the next. + *
  • Depth and length are bounded before parsing. A deeply nested document costs stack + * and heap during parse, so a bound applied after parsing has already paid for the attack. + *
+ * + *

Default typing is never enabled and there is no code here that could enable it. Polymorphic + * type resolution from a wire document is the mechanism behind essentially every Java + * deserialization CVE; the manifest is what this codec resolves against instead. + */ +public final class StrictWebSocketJsonCodec { + + private final JsonMapper mapper; + private final WebSocketMessageCatalog catalog; + private final WebSocketWireTypeManifest manifest; + + /** + * A codec over one catalog and manifest. + * + * @param catalog which types are published + * @param manifest which classes they decode to + * @param budget the bounds applied before parsing + */ + public StrictWebSocketJsonCodec( + WebSocketMessageCatalog catalog, + WebSocketWireTypeManifest manifest, + WebSocketConnectionBudget budget) { + this.catalog = Objects.requireNonNull(catalog, "catalog"); + this.manifest = Objects.requireNonNull(manifest, "manifest"); + Objects.requireNonNull(budget, "budget"); + this.mapper = + JsonMapper.builder( + JsonFactory.builder() + .streamReadConstraints( + StreamReadConstraints.builder() + // Depth costs stack during parse, so it is bounded before parsing + // rather than measured after. + .maxNestingDepth(32) + .maxDocumentLength(budget.maxMessageBytes()) + .maxStringLength(budget.maxMessageBytes()) + .maxNumberLength(64) + .build()) + // Not a default in any parser, and the one that matters most here: a duplicate + // key is accepted everywhere and silently resolved to one value, so the same + // document means different things to a proxy and to this server. + .enable(StreamReadFeature.STRICT_DUPLICATE_DETECTION) + .build()) + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, true) + .configure(DeserializationFeature.FAIL_ON_TRAILING_TOKENS, true) + .configure(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES, true) + .configure(MapperFeature.ACCEPT_CASE_INSENSITIVE_PROPERTIES, false) + .configure(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS, false) + .disable(DeserializationFeature.ACCEPT_FLOAT_AS_INT) + .build(); + } + + /** + * Decodes one inbound payload. + * + * @param type the published type the envelope named + * @param family the family the envelope claimed + * @param payload the encoded document + * @throws WebSocketDecodeException when anything about it is not exactly right + */ + public Object decodeFromClient( + WebSocketMessageType type, WebSocketMessageFamily family, String payload) { + Objects.requireNonNull(type, "type"); + Objects.requireNonNull(family, "family"); + Objects.requireNonNull(payload, "payload"); + + // Catalog first, parser second. Deciding whether a type is admissible before handing bytes to + // a parser is what keeps an unpublished type from reaching one at all. + WebSocketMessageDescriptor descriptor = + catalog + .admitFromClient(type, family) + .orElseThrow( + () -> + new WebSocketDecodeException( + WebSocketFailureCategory.UNKNOWN_TYPE, + "this endpoint does not accept " + type + " from a client")); + + Class target = + manifest + .targetFor(descriptor.type()) + .orElseThrow( + () -> + new WebSocketDecodeException( + WebSocketFailureCategory.INTERNAL, + "the catalog publishes " + + type + + " but the manifest binds no class to it")); + + try { + return mapper.readValue(payload, target); + } catch (RuntimeException malformed) { + // The parser's message names the class, the field and often the offending content. None of + // that goes to the peer: the category is what a client can act on, and the detail stays in + // the server's own logs. + throw new WebSocketDecodeException( + WebSocketFailureCategory.MALFORMED, "the payload is not a valid " + type + " document"); + } + } + + /** + * Encodes an outbound payload. + * + * @param type the published type + * @param value the document to send + */ + public String encodeToClient(WebSocketMessageType type, Object value) { + Objects.requireNonNull(type, "type"); + Objects.requireNonNull(value, "value"); + if (!catalog.acceptsFromServer(type)) { + throw new WebSocketDecodeException( + WebSocketFailureCategory.INTERNAL, + "the server may not send " + type + "; it is not published in that direction"); + } + return mapper.writeValueAsString(value); + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/codec/WebSocketDecodeException.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/codec/WebSocketDecodeException.java new file mode 100644 index 00000000..8dc8b0a8 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/codec/WebSocketDecodeException.java @@ -0,0 +1,34 @@ +package dev.caskeleton.adapter.inbound.websocket.codec; + +import dev.caskeleton.adapter.inbound.websocket.error.WebSocketFailureCategory; +import java.util.Objects; + +/** + * A payload that could not be turned into a declared type. + * + *

Carries the category rather than the parser's own message. What a parser says names the class, + * the field and frequently the offending content — useful in a server log and a disclosure on a + * connection the peer holds for hours. + */ +public final class WebSocketDecodeException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + private final transient WebSocketFailureCategory category; + + /** + * A decode failure. + * + * @param category what a client can act on + * @param publishableDetail a message already safe to send + */ + public WebSocketDecodeException(WebSocketFailureCategory category, String publishableDetail) { + super(Objects.requireNonNull(publishableDetail, "publishableDetail")); + this.category = Objects.requireNonNull(category, "category"); + } + + /** What kind of failure this is. */ + public WebSocketFailureCategory category() { + return category; + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/codec/WebSocketWireTypeManifest.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/codec/WebSocketWireTypeManifest.java new file mode 100644 index 00000000..67d87c27 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/codec/WebSocketWireTypeManifest.java @@ -0,0 +1,61 @@ +package dev.caskeleton.adapter.inbound.websocket.codec; + +import dev.caskeleton.adapter.inbound.websocket.protocol.WebSocketMessageType; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +/** + * The only Java types this deployment will ever deserialize from a wire message. + * + *

An explicit map from published name to class, and the deciding property is that the class + * never comes from the wire. Every deserialization vulnerability of the last decade has the same + * shape: a document names a type, the receiver resolves it, and the resolution reaches something + * with a side effect in its constructor or its setter. A receiver that can only produce classes + * from this map cannot be steered anywhere its author did not put. + * + *

Fixed at startup for the same reason the catalog is. A manifest that could gain an entry at + * runtime is a manifest that does not answer "what can this deployment be made to instantiate". + */ +public final class WebSocketWireTypeManifest { + + private final Map> byType; + + private WebSocketWireTypeManifest(Map> byType) { + this.byType = byType; + } + + /** A manifest over the declared bindings. */ + public static WebSocketWireTypeManifest of(Map> bindings) { + Objects.requireNonNull(bindings, "bindings"); + Map> copy = new LinkedHashMap<>(); + bindings.forEach( + (type, target) -> { + Objects.requireNonNull(type, "type"); + Objects.requireNonNull(target, "target"); + if (!target.isRecord()) { + // Records only. A record has no setters, no no-arg constructor and no mutable state, + // so the whole class of gadgets that runs code during population does not apply — and + // its components are the schema rather than a subset of it. + throw new IllegalArgumentException( + target.getName() + + " is not a record. Wire types are records because a record cannot run" + + " arbitrary code while being populated, which is the mechanism every" + + " deserialization gadget depends on"); + } + copy.put(type, target); + }); + return new WebSocketWireTypeManifest(Map.copyOf(copy)); + } + + /** The class a published type decodes to, if it is bound. */ + public Optional> targetFor(WebSocketMessageType type) { + return Optional.ofNullable(byType.get(type)); + } + + /** Every binding. */ + public Map> bindings() { + return byType; + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/config/WebSocketPlatformSettings.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/config/WebSocketPlatformSettings.java new file mode 100644 index 00000000..77cfd83e --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/config/WebSocketPlatformSettings.java @@ -0,0 +1,60 @@ +package dev.caskeleton.adapter.inbound.websocket.config; + +import java.time.Duration; +import java.util.Set; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.DefaultValue; + +/** + * The WebSocket platform's bound configuration. + * + *

Every default here is the safe one, and where there is no safe default there is no default. + * {@code allowedOrigins} is empty and {@code enabled} is false: a deployment that has not said + * which origins may connect has not configured a WebSocket endpoint, and starting one anyway would + * mean the platform chose its own CSRF posture. + * + *

Bounds are here rather than in code so a deployment can lower them, and the validator refuses + * anything that raises them past the platform ceiling. Configuration may make a deployment more + * careful than the default; it may not make it less careful than the platform allows. + * + * @param enabled whether the platform serves any endpoint + * @param nodeId this node's identity, used as a metric tag and in admin listings + * @param allowedOrigins which origins may open a connection + * @param allowMissingOrigin whether a client sending no Origin is admitted + * @param maxFrameBytes the largest frame accepted + * @param maxMessageBytes the largest reassembled message accepted + * @param maxFragments how many frames one message may span + * @param maxBufferedOutboundBytesPerConnection how much unsent data one connection may hold + * @param maxBufferedOutboundBytesPerNode how much this node may hold across all connections + * @param maxConnectionsPerActor how many connections one caller may hold here + * @param pingInterval how often a connection is checked + * @param idleTimeout how long silence is tolerated + * @param maxConnectionAge how long any connection may live + * @param ticketLifetime how long a connection ticket is valid + */ +@ConfigurationProperties(prefix = "backend.websocket") +public record WebSocketPlatformSettings( + @DefaultValue("false") boolean enabled, + @DefaultValue("local") String nodeId, + @DefaultValue({}) Set allowedOrigins, + @DefaultValue("false") boolean allowMissingOrigin, + @DefaultValue("65536") int maxFrameBytes, + @DefaultValue("524288") int maxMessageBytes, + @DefaultValue("16") int maxFragments, + @DefaultValue("1048576") long maxBufferedOutboundBytesPerConnection, + @DefaultValue("67108864") long maxBufferedOutboundBytesPerNode, + @DefaultValue("4") int maxConnectionsPerActor, + @DefaultValue("15s") Duration pingInterval, + @DefaultValue("45s") Duration idleTimeout, + @DefaultValue("4h") Duration maxConnectionAge, + @DefaultValue("30s") Duration ticketLifetime) { + + public WebSocketPlatformSettings { + allowedOrigins = Set.copyOf(allowedOrigins); + } + + /** Whether this configuration names any origin at all. */ + public boolean hasOriginAllowlist() { + return !allowedOrigins.isEmpty(); + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/config/WebSocketPlatformStartupValidator.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/config/WebSocketPlatformStartupValidator.java new file mode 100644 index 00000000..1e78a1fc --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/config/WebSocketPlatformStartupValidator.java @@ -0,0 +1,125 @@ +package dev.caskeleton.adapter.inbound.websocket.config; + +import dev.caskeleton.adapter.inbound.websocket.authz.MessageAuthorizationPolicy; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketEndpointCatalog; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketEndpointProfile; +import dev.caskeleton.adapter.inbound.websocket.protocol.WebSocketMessageCatalog; +import dev.caskeleton.adapter.inbound.websocket.protocol.WebSocketMessageDescriptor; +import dev.caskeleton.adapter.inbound.websocket.protocol.WebSocketMessageType; +import dev.caskeleton.adapter.inbound.websocket.protocol.WebSocketProtocolProfile; +import dev.caskeleton.adapter.inbound.websocket.security.WebSocketAuthenticationProfile; +import dev.caskeleton.adapter.inbound.websocket.security.WebSocketOriginPolicy; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** + * Refuses to start a deployment whose WebSocket configuration is unsafe or incoherent. + * + *

Every check here catches something that works perfectly in development and is wrong in + * production. That is the whole category: a cookie endpoint with no origin allowlist serves every + * same-origin test correctly, an unnegotiated-fallback profile connects fine until the message + * format changes, and an endpoint with no authorization requirement simply refuses everything — + * quietly, and only for the clients that try to use it. + * + *

Fail-closed, because a warning is read by nobody. The deployment starts, the dashboards are + * green, and the line scrolls past in the first thirty seconds of a log nobody keeps. + */ +public final class WebSocketPlatformStartupValidator { + + private final boolean productionProfile; + + /** + * A validator for one deployment. + * + * @param productionProfile whether this deployment is production; the local profile is allowed + * the fallbacks that production is not + */ + public WebSocketPlatformStartupValidator(boolean productionProfile) { + this.productionProfile = productionProfile; + } + + /** + * Refuses an unsafe configuration. + * + * @param endpoints the declared endpoints + * @param messages the published message catalog + * @param authorization what each type requires + * @param originPolicy which origins may connect + * @param authenticationProfile how callers authenticate + * @param protocolProfile which subprotocols are spoken + * @throws IllegalStateException naming every problem at once + */ + public void validate( + WebSocketEndpointCatalog endpoints, + WebSocketMessageCatalog messages, + MessageAuthorizationPolicy authorization, + WebSocketOriginPolicy originPolicy, + WebSocketAuthenticationProfile authenticationProfile, + WebSocketProtocolProfile protocolProfile) { + Objects.requireNonNull(endpoints, "endpoints"); + Objects.requireNonNull(messages, "messages"); + Objects.requireNonNull(authorization, "authorization"); + Objects.requireNonNull(originPolicy, "originPolicy"); + Objects.requireNonNull(authenticationProfile, "authenticationProfile"); + Objects.requireNonNull(protocolProfile, "protocolProfile"); + + List problems = new ArrayList<>(); + + if (!endpoints.singleStack()) { + // Boot deduces one application type from the classpath, so a catalog spanning both runtimes + // means one set of endpoints is never served and nothing says so. + problems.add( + "endpoints are declared on both runtimes; only one can be served, and the other's" + + " endpoints would silently never answer"); + } + + if (!originPolicy.safeFor(authenticationProfile)) { + // The same-origin policy does not protect a WebSocket handshake and there is no preflight. + // The server's own Origin check is the entire CSRF defence. + problems.add( + "the " + + authenticationProfile + + " profile needs an exact origin allowlist and must refuse a missing origin; a" + + " WebSocket handshake is not protected by the same-origin policy and has no" + + " preflight, so this check is the whole defence"); + } + + if (productionProfile && !protocolProfile.productionReady()) { + problems.add( + "the protocol profile accepts a client that negotiates no subprotocol; both ends then" + + " assume their own current format and find out when it changes"); + } + + if (productionProfile) { + List unsafe = endpoints.productionUnsafe(); + if (!unsafe.isEmpty()) { + problems.add( + "these endpoints require no authentication: " + + unsafe.stream().map(profile -> profile.name().value()).toList() + + "; a WebSocket is a long-lived resource, so an unauthenticated one is an" + + " unauthenticated caller holding server memory for hours"); + } + } + + List undeclared = + authorization.undeclaredAmong( + messages.all().stream().map(WebSocketMessageDescriptor::type).toList()); + if (!undeclared.isEmpty()) { + // Refusing them is safe and is not what anybody intended. Without this the deployment learns + // it from a client that cannot do anything. + problems.add( + "these published message types have no authorization requirement and are therefore" + + " unsendable: " + + undeclared.stream().map(WebSocketMessageType::value).toList()); + } + + if (!problems.isEmpty()) { + // Every problem at once. Reporting the first sends an operator through one restart per + // problem, and each restart is a deploy. + throw new IllegalStateException( + "the WebSocket platform configuration is unsafe:\n - " + + String.join("\n - ", problems)); + } + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/config/WebSocketStackExclusivity.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/config/WebSocketStackExclusivity.java new file mode 100644 index 00000000..de4ed6c5 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/config/WebSocketStackExclusivity.java @@ -0,0 +1,78 @@ +package dev.caskeleton.adapter.inbound.websocket.config; + +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketStackProfile; +import java.util.Optional; + +/** + * Which runtime this deployment can actually serve, decided from the classpath rather than asked. + * + *

Spring Boot deduces one application type from what is present, and the deduction is not + * negotiable: with both a servlet container and Reactor Netty available it starts the servlet one. + * A deployment that declared reactive endpoints and shipped both therefore starts, reports itself + * healthy, and serves none of them — no error, no warning, and the endpoints simply never answer. + * + *

Detecting it here means the mismatch is a startup failure with a sentence explaining it, + * rather than a support ticket that says "the WebSocket does not connect". + */ +public final class WebSocketStackExclusivity { + + private static final String SERVLET_MARKER = "jakarta.servlet.Servlet"; + private static final String REACTIVE_MARKER = + "org.springframework.web.reactive.socket.server.WebSocketService"; + + private WebSocketStackExclusivity() {} + + /** Which runtime the classpath will actually produce, or empty when neither is present. */ + public static Optional resolvedStack() { + boolean servlet = present(SERVLET_MARKER); + boolean reactive = present(REACTIVE_MARKER); + if (servlet) { + // Servlet wins whenever both are present, because that is what Boot's own deduction does. + // Reporting the reactive stack here would be a second, disagreeing deduction. + return Optional.of(WebSocketStackProfile.SERVLET); + } + return reactive ? Optional.of(WebSocketStackProfile.REACTIVE) : Optional.empty(); + } + + /** Whether both runtimes are on the classpath. */ + public static boolean bothPresent() { + return present(SERVLET_MARKER) && present(REACTIVE_MARKER); + } + + /** + * Refuses a deployment whose declared stack is not the one it can serve. + * + * @param declared what the endpoints say they need + * @throws IllegalStateException when the classpath cannot serve it + */ + public static void require(WebSocketStackProfile declared) { + Optional resolved = resolvedStack(); + if (resolved.isEmpty()) { + throw new IllegalStateException( + "no WebSocket runtime is on the classpath; the declared " + + declared + + " endpoints would never answer"); + } + if (resolved.get() != declared) { + throw new IllegalStateException( + "the endpoints declare " + + declared + + " but the classpath resolves to " + + resolved.get() + + (bothPresent() + ? ". Both runtimes are present, and Spring Boot deduces one application type" + + " from the classpath — so the declared endpoints would never answer while" + + " the deployment reported itself healthy. Remove the unused starter." + : ". Add the runtime the endpoints need, or change what they declare.")); + } + } + + private static boolean present(String className) { + try { + Class.forName(className, false, WebSocketStackExclusivity.class.getClassLoader()); + return true; + } catch (ClassNotFoundException absent) { + return false; + } + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/core/WebSocketActorReference.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/core/WebSocketActorReference.java new file mode 100644 index 00000000..a8214cf2 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/core/WebSocketActorReference.java @@ -0,0 +1,79 @@ +package dev.caskeleton.adapter.inbound.websocket.core; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.Objects; + +/** + * A stable, non-reversible stand-in for who is on the other end. + * + *

The connection context is read by the metrics recorder, the access log, the admin listing and + * anything that debugs a stuck connection. A raw subject or tenant in that record is a subject or + * tenant in all of those, and unlike a request log this one persists for the life of the + * connection. + * + *

A fingerprint keeps what those readers actually need — the ability to tell two connections + * apart and to recognise the same actor twice — and drops what they do not, which is who it is. + * Answering "which user is this" is the admin plane's job, through a lookup that is authorised and + * audited rather than through a field that is simply present. + * + * @param fingerprint a hex digest of the actor's identity under a deployment salt + */ +public record WebSocketActorReference(String fingerprint) { + + private static final int HEX_LENGTH = 64; + + public WebSocketActorReference { + Objects.requireNonNull(fingerprint, "fingerprint"); + if (fingerprint.length() != HEX_LENGTH + || !fingerprint.chars().allMatch(WebSocketActorReference::isHex)) { + throw new IllegalArgumentException("an actor reference is a 64-character hex digest"); + } + } + + /** + * Derives a reference from an identity. + * + * @param subject the authenticated subject + * @param tenantId the tenant, or null when the connection is not tenant scoped + * @param salt a per-deployment secret + */ + public static WebSocketActorReference of(String subject, String tenantId, byte[] salt) { + Objects.requireNonNull(subject, "subject"); + Objects.requireNonNull(salt, "salt"); + if (salt.length < 16) { + // Without a real salt the digest is a rainbow table away from the subject it was meant to + // hide, and subjects are drawn from small predictable sets — email addresses, user ids. + throw new IllegalArgumentException( + "an actor reference needs at least 16 bytes of salt; subjects come from small guessable" + + " sets and an unsalted digest of one is the subject"); + } + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + digest.update(salt); + digest.update((byte) 0x1f); + digest.update(subject.getBytes(StandardCharsets.UTF_8)); + digest.update((byte) 0x1f); + digest.update((tenantId == null ? "" : tenantId).getBytes(StandardCharsets.UTF_8)); + return new WebSocketActorReference(HexFormat.of().formatHex(digest.digest())); + } catch (NoSuchAlgorithmException impossible) { + throw new IllegalStateException("SHA-256 is required of every JVM", impossible); + } + } + + /** + * A short prefix, for a log line where the whole digest is noise. + * + *

Twelve characters: enough to tell connections apart while reading, short enough that it is + * obviously not the identity itself. + */ + public String shortForm() { + return fingerprint.substring(0, 12); + } + + private static boolean isHex(int character) { + return (character >= '0' && character <= '9') || (character >= 'a' && character <= 'f'); + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/core/WebSocketConnectionContext.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/core/WebSocketConnectionContext.java new file mode 100644 index 00000000..d77db75b --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/core/WebSocketConnectionContext.java @@ -0,0 +1,97 @@ +package dev.caskeleton.adapter.inbound.websocket.core; + +import java.time.Instant; +import java.util.Objects; +import java.util.Optional; + +/** + * Everything the platform knows about one connection, and nothing the transport owns. + * + *

No {@code WebSocketSession}, no {@code ServerHttpRequest}, no channel. That absence is the + * design's rule and it earns its keep twice: the same context serves the servlet and reactive + * runtimes unchanged, and every decision made from it is testable without starting a server. + * + *

Immutable after OPEN, which the state machine enforces rather than documenting. A connection + * whose actor could change mid-life would make every authorization decision conditional on when it + * was asked — and the audit trail would record the last actor rather than the one who acted. + * + * @param connectionId this connection + * @param sessionId the logical session, which may span a reconnect + * @param nodeId which node holds it + * @param endpoint which endpoint it connected to + * @param subprotocol what was negotiated, or empty on an unnegotiated fallback + * @param actor a non-reversible reference to who is on the other end + * @param state where it is in its life + * @param credentialExpiry when its authorisation stops being valid + * @param openedAt when the handshake completed + */ +public record WebSocketConnectionContext( + WebSocketConnectionId connectionId, + WebSocketSessionId sessionId, + WebSocketNodeId nodeId, + WebSocketEndpointName endpoint, + Optional subprotocol, + WebSocketActorReference actor, + WebSocketConnectionState state, + WebSocketCredentialExpiry credentialExpiry, + Instant openedAt) { + + public WebSocketConnectionContext { + Objects.requireNonNull(connectionId, "connectionId"); + Objects.requireNonNull(sessionId, "sessionId"); + Objects.requireNonNull(nodeId, "nodeId"); + Objects.requireNonNull(endpoint, "endpoint"); + Objects.requireNonNull(subprotocol, "subprotocol"); + Objects.requireNonNull(actor, "actor"); + Objects.requireNonNull(state, "state"); + Objects.requireNonNull(credentialExpiry, "credentialExpiry"); + Objects.requireNonNull(openedAt, "openedAt"); + } + + /** + * The same connection in a new state. + * + * @throws IllegalStateException when the transition is not one the state machine allows + */ + public WebSocketConnectionContext transitionTo(WebSocketConnectionState next) { + if (!state.canTransitionTo(next)) { + throw new IllegalStateException( + "a connection cannot go from " + + state + + " to " + + next + + "; every allowed transition moves forward, and going back would let a node that" + + " announced it was draining quietly resume taking work"); + } + return new WebSocketConnectionContext( + connectionId, + sessionId, + nodeId, + endpoint, + subprotocol, + actor, + next, + credentialExpiry, + openedAt); + } + + /** + * Whether this connection has outlived a bound on its total age. + * + *

Separate from credential expiry, and both are needed. A never-expiring service credential + * still should not hold one connection open across three deploys: a maximum age is what forces a + * client back through the handshake, where the current policy is applied. + * + * @param maximumAge how long any connection may live + * @param now the current instant + */ + public boolean olderThan(java.time.Duration maximumAge, Instant now) { + Objects.requireNonNull(maximumAge, "maximumAge"); + return !now.isBefore(openedAt.plus(maximumAge)); + } + + /** Whether the platform may still write to this connection. */ + public boolean writable() { + return state.writable(); + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/core/WebSocketConnectionId.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/core/WebSocketConnectionId.java new file mode 100644 index 00000000..2af37582 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/core/WebSocketConnectionId.java @@ -0,0 +1,36 @@ +package dev.caskeleton.adapter.inbound.websocket.core; + +import java.util.Objects; +import java.util.regex.Pattern; + +/** + * One live connection, for as long as it is open. + * + *

Opaque by construction. It carries no credential, no database key and no tenant meaning. A + * connection id that encoded any of those would leak them everywhere the id travels — into logs, + * into admin listings, into whatever a client is handed for support purposes — and it would still + * be guessable by whoever learned the encoding. + * + *

It is also not a metric tag. One value per connection is unbounded by definition, and a tag + * that grows with traffic multiplies every series that carries it. + * + * @param value the identifier + */ +public record WebSocketConnectionId(String value) { + + private static final Pattern GRAMMAR = Pattern.compile("[A-Za-z0-9_-]{8,64}"); + + public WebSocketConnectionId { + Objects.requireNonNull(value, "value"); + if (!GRAMMAR.matcher(value).matches()) { + throw new IllegalArgumentException( + "a websocketconnectionid must match [A-Za-z0-9_-]{8,64}; anything longer is unbounded and anything" + + " shorter is guessable"); + } + } + + @Override + public String toString() { + return value; + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/core/WebSocketConnectionState.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/core/WebSocketConnectionState.java new file mode 100644 index 00000000..53ec91d3 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/core/WebSocketConnectionState.java @@ -0,0 +1,75 @@ +package dev.caskeleton.adapter.inbound.websocket.core; + +import java.util.Set; + +/** + * Where a connection is in its life. + * + *

Transitions are one-way and checked, because every state here corresponds to a different + * answer to "may I still write to this?". A platform that treats the question as a boolean writes + * to a draining connection, or refuses to write to one that is merely mid-handshake, and both look + * like flaky delivery from the client's side. + * + *

{@link #DRAINING} is the member that is easy to omit and expensive to lack. Without it a + * rolling restart has only "open" and "closed": either the connection keeps accepting new work + * until it is cut off mid-message, or it is closed abruptly and the client reconnects into the node + * that is also about to go away. + */ +public enum WebSocketConnectionState { + + /** The handshake is being admitted; nothing may be written yet. */ + CONNECTING, + + /** Established and negotiated. Reads and writes are allowed. */ + OPEN, + + /** + * Closing gracefully: no new inbound work is accepted, in-flight writes finish. + * + *

The state a rolling restart needs. Skipping it means choosing between cutting a message in + * half and letting a departing node keep taking work. + */ + DRAINING, + + /** A close frame has been sent or received; the handshake is unwinding. */ + CLOSING, + + /** Terminal. Nothing may be read or written. */ + CLOSED; + + /** Whether the platform may still write application messages. */ + public boolean writable() { + // DRAINING is writable on purpose: the whole point is that in-flight work finishes. + return this == OPEN || this == DRAINING; + } + + /** Whether new inbound application messages may still be accepted. */ + public boolean acceptsInbound() { + return this == OPEN; + } + + /** Whether no further transition is possible. */ + public boolean terminal() { + return this == CLOSED; + } + + /** + * Whether this state may become {@code next}. + * + *

Every allowed edge moves forward. A connection that could go back to OPEN from DRAINING + * would let a node that announced it was leaving quietly resume taking work, which is the one + * thing a drain has to prevent. + */ + public boolean canTransitionTo(WebSocketConnectionState next) { + if (next == null || next == this) { + return false; + } + return switch (this) { + case CONNECTING -> Set.of(OPEN, CLOSING, CLOSED).contains(next); + case OPEN -> Set.of(DRAINING, CLOSING, CLOSED).contains(next); + case DRAINING -> Set.of(CLOSING, CLOSED).contains(next); + case CLOSING -> next == CLOSED; + case CLOSED -> false; + }; + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/core/WebSocketCredentialExpiry.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/core/WebSocketCredentialExpiry.java new file mode 100644 index 00000000..30cec22a --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/core/WebSocketCredentialExpiry.java @@ -0,0 +1,60 @@ +package dev.caskeleton.adapter.inbound.websocket.core; + +import java.time.Duration; +import java.time.Instant; +import java.util.Objects; +import java.util.Optional; + +/** + * When the credential that authorised this connection stops being valid. + * + *

The problem a long-lived connection has and a request does not. An HTTP request is + * authenticated and finished; a WebSocket is authenticated once and then runs for hours. Without an + * expiry the connection outlives the token, the revoked session, and the role change — and the + * caller keeps receiving data it is no longer entitled to, with nothing in any log to say so. + * + * @param expiresAt when the credential stops being valid, or empty when it does not expire + * @param graceBeforeClose how long after expiry the connection is given to re-authenticate + */ +public record WebSocketCredentialExpiry(Optional expiresAt, Duration graceBeforeClose) { + + public WebSocketCredentialExpiry { + Objects.requireNonNull(expiresAt, "expiresAt"); + Objects.requireNonNull(graceBeforeClose, "graceBeforeClose"); + if (graceBeforeClose.isNegative()) { + throw new IllegalArgumentException("a negative grace period is not a grace period"); + } + if (graceBeforeClose.compareTo(Duration.ofMinutes(5)) > 0) { + // A long grace is an expiry that does not expire. Five minutes is enough for a client to + // notice and re-authenticate; an hour is enough for a revoked session to stay live. + throw new IllegalArgumentException( + "a grace period over five minutes makes the expiry advisory: a revoked credential stays" + + " live for as long as the grace lasts"); + } + } + + /** A credential that never expires, for a service connection with a static credential. */ + public static WebSocketCredentialExpiry never() { + return new WebSocketCredentialExpiry(Optional.empty(), Duration.ZERO); + } + + /** + * A credential valid until an instant, with a grace period to re-authenticate. + * + * @param expiresAt when it stops being valid + * @param graceBeforeClose how long the connection may continue past that + */ + public static WebSocketCredentialExpiry at(Instant expiresAt, Duration graceBeforeClose) { + return new WebSocketCredentialExpiry(Optional.of(expiresAt), graceBeforeClose); + } + + /** Whether the credential has expired. */ + public boolean expiredAt(Instant now) { + return expiresAt.map(expiry -> !now.isBefore(expiry)).orElse(false); + } + + /** Whether the grace period has also elapsed, so the connection must be closed. */ + public boolean mustCloseAt(Instant now) { + return expiresAt.map(expiry -> !now.isBefore(expiry.plus(graceBeforeClose))).orElse(false); + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/core/WebSocketEndpointCatalog.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/core/WebSocketEndpointCatalog.java new file mode 100644 index 00000000..af509598 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/core/WebSocketEndpointCatalog.java @@ -0,0 +1,111 @@ +package dev.caskeleton.adapter.inbound.websocket.core; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +/** + * Every endpoint this deployment serves, fixed at startup. + * + *

Sealed after construction on purpose. A registry that accepts a new endpoint at runtime means + * the set of things reachable from the internet is not knowable from the code, and the admin + * snapshot, the startup validator and the security review are each describing a moment rather than + * the deployment. + * + *

It also refuses two endpoints on the same path. Two handlers on one path is a coin flip + * decided by registration order, and the loser is silently unreachable — which looks exactly like a + * client bug. + */ +public final class WebSocketEndpointCatalog { + + private final Map byName; + private final Map byPath; + + private WebSocketEndpointCatalog( + Map byName, + Map byPath) { + this.byName = byName; + this.byPath = byPath; + } + + /** + * A catalog over the declared endpoints. + * + * @param profiles every endpoint this deployment serves + * @throws IllegalArgumentException when two endpoints share a name or a path + */ + public static WebSocketEndpointCatalog of(List profiles) { + Objects.requireNonNull(profiles, "profiles"); + Map byName = new LinkedHashMap<>(); + Map byPath = new LinkedHashMap<>(); + for (WebSocketEndpointProfile profile : profiles) { + if (byName.putIfAbsent(profile.name(), profile) != null) { + throw new IllegalArgumentException("two endpoints are named " + profile.name()); + } + WebSocketEndpointName previous = byPath.putIfAbsent(profile.path(), profile.name()); + if (previous != null) { + throw new IllegalArgumentException( + "endpoints " + + previous + + " and " + + profile.name() + + " both listen on " + + profile.path() + + "; which one answers would be decided by registration order and the other would" + + " be silently unreachable"); + } + } + return new WebSocketEndpointCatalog(Map.copyOf(byName), Map.copyOf(byPath)); + } + + /** The endpoint listening on a path, if any. */ + public Optional findByPath(String path) { + return Optional.ofNullable(byPath.get(path)).map(byName::get); + } + + /** The named endpoint, if declared. */ + public Optional find(WebSocketEndpointName name) { + return Optional.ofNullable(byName.get(name)); + } + + /** + * The named endpoint. + * + * @throws IllegalArgumentException when it is not declared + */ + public WebSocketEndpointProfile require(WebSocketEndpointName name) { + return find(name) + .orElseThrow( + () -> + new IllegalArgumentException( + "no endpoint named " + name + "; the declared set is " + byName.keySet())); + } + + /** Every declared endpoint. */ + public List all() { + return List.copyOf(byName.values()); + } + + /** + * The endpoints that are not safe to serve in production. + * + *

Returned rather than thrown, so the startup validator decides. A local profile legitimately + * serves an unauthenticated endpoint, and a catalog that refused to build would make the local + * case impossible rather than deliberate. + */ + public List productionUnsafe() { + return byName.values().stream().filter(profile -> !profile.productionSafe()).toList(); + } + + /** + * Whether every endpoint runs on one stack. + * + *

The two runtimes are mutually exclusive: Boot deduces one application type from the + * classpath, so a catalog spanning both means one set of endpoints is silently never served. + */ + public boolean singleStack() { + return byName.values().stream().map(WebSocketEndpointProfile::stack).distinct().count() <= 1; + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/core/WebSocketEndpointName.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/core/WebSocketEndpointName.java new file mode 100644 index 00000000..8e743096 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/core/WebSocketEndpointName.java @@ -0,0 +1,31 @@ +package dev.caskeleton.adapter.inbound.websocket.core; + +import java.util.Objects; +import java.util.regex.Pattern; + +/** + * The name of a declared endpoint. + * + *

Bounded and lowercase, because this value reaches a metric tag, a log field and an admin + * listing. An endpoint set that grows with traffic is a cardinality problem; one that differs only + * by case is two entries in every dashboard for one endpoint. + * + * @param value the endpoint name + */ +public record WebSocketEndpointName(String value) { + + private static final Pattern GRAMMAR = Pattern.compile("[a-z][a-z0-9-]{1,63}"); + + public WebSocketEndpointName { + Objects.requireNonNull(value, "value"); + if (!GRAMMAR.matcher(value).matches()) { + throw new IllegalArgumentException( + "an endpoint name must match [a-z][a-z0-9-]{1,63}, was '" + value + "'"); + } + } + + @Override + public String toString() { + return value; + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/core/WebSocketEndpointProfile.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/core/WebSocketEndpointProfile.java new file mode 100644 index 00000000..2b80b376 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/core/WebSocketEndpointProfile.java @@ -0,0 +1,70 @@ +package dev.caskeleton.adapter.inbound.websocket.core; + +import dev.caskeleton.adapter.inbound.websocket.budget.WebSocketConnectionBudget; +import java.util.Objects; +import java.util.regex.Pattern; + +/** + * One declared endpoint: where it listens, what it speaks, who may reach it, what it may consume. + * + *

A record rather than scattered configuration, so the incoherent combinations are refused at + * construction. The one worth naming is an endpoint that requires no authentication and still + * carries a real connection budget: a WebSocket is a long-lived resource, so an unauthenticated + * endpoint is an unauthenticated caller holding server memory for hours. + * + * @param name the endpoint's identity + * @param path the canonical absolute path it listens on + * @param stack which runtime serves it + * @param requiresAuthentication whether a handshake must be authenticated + * @param budget what one connection may consume + * @param maxConcurrentConnections how many connections this endpoint may hold at once + */ +public record WebSocketEndpointProfile( + WebSocketEndpointName name, + String path, + WebSocketStackProfile stack, + boolean requiresAuthentication, + WebSocketConnectionBudget budget, + int maxConcurrentConnections) { + + private static final Pattern CANONICAL_PATH = Pattern.compile("(/[A-Za-z0-9._~-]+)+"); + + public WebSocketEndpointProfile { + Objects.requireNonNull(name, "name"); + Objects.requireNonNull(path, "path"); + Objects.requireNonNull(stack, "stack"); + Objects.requireNonNull(budget, "budget"); + if (!CANONICAL_PATH.matcher(path).matches()) { + // Canonical, so the path that is matched is the path that was declared. A trailing slash, a + // dot segment or an encoded character makes the route that answers differ from the route + // anyone reviewed. + throw new IllegalArgumentException( + "an endpoint path must be canonical and absolute, was '" + + path + + "'; a trailing slash or a dot segment makes the route that answers differ from the" + + " one that was declared"); + } + if (path.contains("..")) { + throw new IllegalArgumentException("an endpoint path may not contain a dot segment"); + } + if (maxConcurrentConnections <= 0) { + // The design's rule, and it is about memory rather than tidiness: every connection holds + // buffers, and an endpoint with no cap is an endpoint whose memory use is set by whoever + // connects the most. + throw new IllegalArgumentException( + "every endpoint needs a positive connection cap; without one its memory use is decided" + + " by whoever connects the most"); + } + } + + /** + * Whether this endpoint is safe to serve in production. + * + *

Advisory, and read by the startup validator. An unauthenticated endpoint is not always wrong + * — a public status feed is a real thing — but it is never right by default, so it is something a + * deployment has to say out loud. + */ + public boolean productionSafe() { + return requiresAuthentication; + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/core/WebSocketNodeId.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/core/WebSocketNodeId.java new file mode 100644 index 00000000..4a7e9e81 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/core/WebSocketNodeId.java @@ -0,0 +1,31 @@ +package dev.caskeleton.adapter.inbound.websocket.core; + +import java.util.Objects; +import java.util.regex.Pattern; + +/** + * Which node holds a connection. + * + *

Bounded and low-cardinality, unlike the connection and session identifiers, because a fleet + * has a knowable number of nodes. That is what makes this the one identifier here that is safe as a + * metric tag — and the reason it exists separately rather than being folded into the connection id. + * + * @param value the node identifier + */ +public record WebSocketNodeId(String value) { + + private static final Pattern GRAMMAR = Pattern.compile("[a-z0-9][a-z0-9._-]{0,63}"); + + public WebSocketNodeId { + Objects.requireNonNull(value, "value"); + if (!GRAMMAR.matcher(value).matches()) { + throw new IllegalArgumentException( + "a node id must match [a-z0-9][a-z0-9._-]{0,63}, was '" + value + "'"); + } + } + + @Override + public String toString() { + return value; + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/core/WebSocketSessionId.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/core/WebSocketSessionId.java new file mode 100644 index 00000000..968ea9d7 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/core/WebSocketSessionId.java @@ -0,0 +1,35 @@ +package dev.caskeleton.adapter.inbound.websocket.core; + +import java.util.Objects; +import java.util.regex.Pattern; + +/** + * A logical session, which may outlive a single connection across a reconnect. + * + *

Opaque by construction. Distinct from the connection id precisely so that a reconnect can be + * recognised without reusing a transport identity. It still encodes nothing: a session id that + * embedded the actor would make every reconnect a disclosure. + * + *

It is also not a metric tag. One value per connection is unbounded by definition, and a tag + * that grows with traffic multiplies every series that carries it. + * + * @param value the identifier + */ +public record WebSocketSessionId(String value) { + + private static final Pattern GRAMMAR = Pattern.compile("[A-Za-z0-9_-]{8,64}"); + + public WebSocketSessionId { + Objects.requireNonNull(value, "value"); + if (!GRAMMAR.matcher(value).matches()) { + throw new IllegalArgumentException( + "a websocketsessionid must match [A-Za-z0-9_-]{8,64}; anything longer is unbounded and anything" + + " shorter is guessable"); + } + } + + @Override + public String toString() { + return value; + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/core/WebSocketStackProfile.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/core/WebSocketStackProfile.java new file mode 100644 index 00000000..a9db035f --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/core/WebSocketStackProfile.java @@ -0,0 +1,18 @@ +package dev.caskeleton.adapter.inbound.websocket.core; + +/** + * Which runtime serves an endpoint. + * + *

Declared per endpoint and validated at startup, because the two runtimes cannot coexist: Boot + * deduces one application type from the classpath, so a deployment carrying both starts one and + * silently serves none of the other's endpoints. Naming the stack is what lets the startup + * validator refuse that instead of discovering it when a client cannot connect. + */ +public enum WebSocketStackProfile { + + /** Servlet containers: Tomcat, Jetty. */ + SERVLET, + + /** Reactor Netty. */ + REACTIVE +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/core/WebSocketSubprotocolName.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/core/WebSocketSubprotocolName.java new file mode 100644 index 00000000..ee37d722 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/core/WebSocketSubprotocolName.java @@ -0,0 +1,59 @@ +package dev.caskeleton.adapter.inbound.websocket.core; + +import java.util.Objects; +import java.util.regex.Pattern; + +/** + * A subprotocol token as it appears in {@code Sec-WebSocket-Protocol}. + * + *

Versioned in the token, which is the only place a WebSocket has to put it. HTTP has content + * negotiation and a URL to version; a WebSocket has one header exchanged once at handshake. A + * subprotocol without a version leaves both ends guessing whose idea of the message format is + * current, and the mismatch shows up as a decode failure on a connection that has been open for + * hours. + * + *

In {@code core} rather than {@code protocol}, which is where the design's module list puts it. + * The connection context has to name the negotiated token, and the context is core — so leaving + * this type in {@code protocol} made {@code core} depend on {@code protocol} while {@code protocol} + * already depended on {@code core}. The module boundary test refused the cycle, which is what it is + * for. + * + *

The split that survives is the useful one: the identifier and its grammar are core, and the + * negotiation policy — {@code WebSocketProtocolProfile} — stays in {@code protocol}. + * + * @param value the token + */ +public record WebSocketSubprotocolName(String value) { + + /** + * The grammar RFC 6455 allows: an HTTP token, and no comma because the header separates on it. + */ + private static final Pattern GRAMMAR = Pattern.compile("[A-Za-z0-9!#$%&'*+.^_`|~-]{1,64}"); + + private static final Pattern VERSIONED = Pattern.compile(".*\\.v[0-9]+(\\..+)?"); + + public WebSocketSubprotocolName { + Objects.requireNonNull(value, "value"); + if (!GRAMMAR.matcher(value).matches()) { + throw new IllegalArgumentException( + "a subprotocol token must be an HTTP token of at most 64 characters, was '" + + value + + "'"); + } + } + + /** The Stable production subprotocol. */ + public static WebSocketSubprotocolName stable() { + return new WebSocketSubprotocolName("hyeonworks.realtime.v1.json"); + } + + /** Whether the token carries a version segment. */ + public boolean versioned() { + return VERSIONED.matcher(value).matches(); + } + + @Override + public String toString() { + return value; + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/error/WebSocketCloseCode.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/error/WebSocketCloseCode.java new file mode 100644 index 00000000..6e17d9b5 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/error/WebSocketCloseCode.java @@ -0,0 +1,95 @@ +package dev.caskeleton.adapter.inbound.websocket.error; + +/** + * The close codes this platform sends, and what each one means to a client. + * + *

RFC 6455 reserves 1000–2999 and leaves 4000–4999 to the application. The distinction matters + * more than it looks: a browser surfaces the standard codes with its own wording and treats the + * private range as opaque, so a code chosen from the wrong range either says something the platform + * did not mean or says nothing at all. + * + *

1005 and 1006 are deliberately absent. They are codes a browser *reports* when no close frame + * arrived — sending them is not possible and is refused here rather than discovered at the + * transport layer. + */ +public enum WebSocketCloseCode { + + /** 1000: the purpose is fulfilled. */ + NORMAL(1000), + + /** 1001: the server is going away — a deploy, a drain. */ + GOING_AWAY(1001), + + /** 1008: a policy was violated in a way that cannot be tolerated. */ + POLICY_VIOLATION(1008), + + /** + * 1009: the message is too big to process. + * + *

Standard and specific, which is why the platform uses it rather than a private code. A + * browser tells the developer what happened without the client library knowing anything about + * this API. + */ + MESSAGE_TOO_BIG(1009), + + /** 1011: the server hit a condition it cannot recover from for this connection. */ + INTERNAL_ERROR(1011), + + /** + * 1013: try again later. + * + *

The standard "temporary overload" code, and the right first choice. A client library that + * knows nothing about this API still backs off, where a private code would be opaque to it. + */ + TRY_AGAIN_LATER(1013), + + /** 4401: the credential expired and was not renewed inside the grace period. */ + CREDENTIAL_EXPIRED(4401), + + /** 4403: the caller is no longer authorised for this connection. */ + NOT_AUTHORIZED(4403), + + /** 4408: the connection was idle past its timeout. */ + IDLE_TIMEOUT(4408), + + /** 4423: the connection outlived its maximum age and must re-handshake. */ + MAX_AGE_REACHED(4423), + + /** + * 4503: shed for capacity, with this deployment's own meaning. + * + *

Alongside 1013 rather than instead of it: 1013 is what a generic client understands, and + * 4503 is what this platform's own client uses to distinguish load shedding from a transient + * server condition. A deployment sends whichever the peer can act on. + */ + SHED_FOR_CAPACITY(4503); + + private final int code; + + WebSocketCloseCode(int code) { + this.code = code; + } + + /** The numeric code sent in the close frame. */ + public int code() { + return code; + } + + /** Whether this is a code the application range owns. */ + public boolean applicationRange() { + return code >= 4000 && code <= 4999; + } + + /** + * Whether a close frame may carry this code. + * + *

1005 and 1006 never can: they are what a peer reports when no close frame arrived, so + * sending them is a contradiction. + */ + public static boolean sendable(int code) { + if (code == 1005 || code == 1006 || code == 1015) { + return false; + } + return (code >= 1000 && code <= 1014) || (code >= 3000 && code <= 4999); + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/error/WebSocketClosePolicy.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/error/WebSocketClosePolicy.java new file mode 100644 index 00000000..65d02af4 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/error/WebSocketClosePolicy.java @@ -0,0 +1,73 @@ +package dev.caskeleton.adapter.inbound.websocket.error; + +import java.util.Objects; +import java.util.Optional; + +/** + * Whether a failure ends the connection, and with what code. + * + *

The decision lives here rather than at each throw site so the two stacks cannot answer it + * differently, and so the answer is reviewable as one table instead of scattered across handlers. + * + *

The rule it encodes: a failure caused by one message closes nothing, and a failure that + * invalidates the connection closes it. Getting that backwards in either direction is expensive — + * disconnecting for a malformed command produces a reconnect storm, because the client reconnects + * and sends the same bad message; not disconnecting on an expired credential leaves an unauthorised + * peer receiving data. + */ +public final class WebSocketClosePolicy { + + private WebSocketClosePolicy() {} + + /** + * The close code for a failure, or empty when the connection survives it. + * + * @param category what went wrong + */ + public static Optional closeCodeFor(WebSocketFailureCategory category) { + Objects.requireNonNull(category, "category"); + return switch (category) { + // Answered on the connection, never closed. The client's bug is in one message. + case MALFORMED, + UNKNOWN_TYPE, + VALIDATION_FAILED, + EXPIRED, + NOT_AUTHORIZED, + RATE_LIMITED, + INTERNAL -> + Optional.empty(); + // The connection itself is no longer authorised. Answering and continuing would leave an + // unauthorised peer subscribed. + case CREDENTIAL_EXPIRED -> Optional.of(WebSocketCloseCode.CREDENTIAL_EXPIRED); + // 1009 rather than a private code: a browser surfaces it with its own wording, so the + // developer learns what happened without the client library knowing this API. + case TOO_LARGE -> Optional.of(WebSocketCloseCode.MESSAGE_TOO_BIG); + case OVERLOADED -> Optional.of(WebSocketCloseCode.TRY_AGAIN_LATER); + }; + } + + /** + * Whether a failure is reported before the handshake completes. + * + *

Before 101 there is no WebSocket, so the answer is an HTTP status and a problem document — a + * close frame at that point is a frame on a connection that was never upgraded. After 101 there + * is no status line left to send, so the answer is a typed error message or a close frame. + * + *

This is the split that gets implemented wrong most often, and the symptom is a client that + * sees a connection open and immediately close with no explanation, because the server tried to + * send a 403 to a socket that had already been upgraded. + * + * @param category what went wrong + * @param handshakeCompleted whether the 101 has been sent + */ + public static WebSocketErrorTransport transportFor( + WebSocketFailureCategory category, boolean handshakeCompleted) { + Objects.requireNonNull(category, "category"); + if (!handshakeCompleted) { + return WebSocketErrorTransport.HTTP_PROBLEM; + } + return closeCodeFor(category).isPresent() + ? WebSocketErrorTransport.CLOSE_FRAME + : WebSocketErrorTransport.TYPED_ERROR_MESSAGE; + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/error/WebSocketErrorMessage.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/error/WebSocketErrorMessage.java new file mode 100644 index 00000000..75187813 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/error/WebSocketErrorMessage.java @@ -0,0 +1,75 @@ +package dev.caskeleton.adapter.inbound.websocket.error; + +import java.util.Objects; +import java.util.Optional; + +/** + * A failure as the peer receives it. + * + *

The correlation is optional and its absence is meaningful: an error answering a command names + * it, and an unsolicited one — a credential expiring, a subscription being revoked — has nothing to + * name. A client with several requests in flight uses that to decide whether to fail one call or + * surface a connection-level problem. + * + *

The detail is written for a client and is not the exception's message. Whatever a decoder or a + * database threw describes the server's internals, and on a connection the peer holds for hours + * that text is retained far longer than a request's error body would be. + * + * @param category what went wrong + * @param detail a message already safe to publish + * @param correlationId the message this answers, when it answers one + * @param retryAfterMillis how long to wait before retrying, when retrying makes sense + */ +public record WebSocketErrorMessage( + WebSocketFailureCategory category, + String detail, + Optional correlationId, + Optional retryAfterMillis) { + + public WebSocketErrorMessage { + Objects.requireNonNull(category, "category"); + Objects.requireNonNull(detail, "detail"); + Objects.requireNonNull(correlationId, "correlationId"); + Objects.requireNonNull(retryAfterMillis, "retryAfterMillis"); + if (detail.isBlank()) { + throw new IllegalArgumentException("an error with no detail tells the client nothing"); + } + if (retryAfterMillis.isPresent() && !category.retryable()) { + // A retry hint on a permanent failure is an invitation to a loop that can only end the same + // way. + throw new IllegalArgumentException( + category + " is not retryable, so a retry hint would invite a loop that cannot succeed"); + } + retryAfterMillis.ifPresent( + millis -> { + if (millis <= 0) { + throw new IllegalArgumentException("a retry delay of " + millis + "ms is not a delay"); + } + }); + } + + /** An error answering a specific message. */ + public static WebSocketErrorMessage answering( + WebSocketFailureCategory category, String detail, String correlationId) { + return new WebSocketErrorMessage( + category, detail, Optional.of(correlationId), Optional.empty()); + } + + /** An error about the connection rather than about a message. */ + public static WebSocketErrorMessage unsolicited( + WebSocketFailureCategory category, String detail) { + return new WebSocketErrorMessage(category, detail, Optional.empty(), Optional.empty()); + } + + /** A retryable error with a delay. */ + public static WebSocketErrorMessage retryable( + WebSocketFailureCategory category, String detail, long retryAfterMillis) { + return new WebSocketErrorMessage( + category, detail, Optional.empty(), Optional.of(retryAfterMillis)); + } + + /** Whether the connection ends after this error. */ + public boolean fatal() { + return WebSocketClosePolicy.closeCodeFor(category).isPresent(); + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/error/WebSocketErrorTransport.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/error/WebSocketErrorTransport.java new file mode 100644 index 00000000..a29e6951 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/error/WebSocketErrorTransport.java @@ -0,0 +1,21 @@ +package dev.caskeleton.adapter.inbound.websocket.error; + +/** + * How a failure reaches the peer. + * + *

Three ways, and which one applies is decided by whether the handshake completed rather than by + * how bad the failure is. Before the 101 the connection is still HTTP and there is a status line; + * after it there is neither, and code that tries to send a status anyway produces a client that + * sees a socket open and close immediately with nothing to explain it. + */ +public enum WebSocketErrorTransport { + + /** Before 101: an HTTP status with an RFC 9457 problem document. */ + HTTP_PROBLEM, + + /** After 101, connection survives: a typed error message on the connection. */ + TYPED_ERROR_MESSAGE, + + /** After 101, connection ends: a close frame with a code. */ + CLOSE_FRAME +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/error/WebSocketFailureCategory.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/error/WebSocketFailureCategory.java new file mode 100644 index 00000000..2fb66550 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/error/WebSocketFailureCategory.java @@ -0,0 +1,59 @@ +package dev.caskeleton.adapter.inbound.websocket.error; + +/** + * What went wrong, in the terms a client can act on. + * + *

Separate from the close code because the two answer different questions. The close code says + * what happens to the connection; the category says what the client should do about the message. + * Most failures do not close anything — a single malformed command on an otherwise healthy + * connection should be answered, not disconnected — and a platform that only had close codes would + * have to tear down a connection to report a typo. + */ +public enum WebSocketFailureCategory { + + /** The frame or document could not be parsed. */ + MALFORMED, + + /** It parsed but names a type this deployment does not publish. */ + UNKNOWN_TYPE, + + /** It parsed and bound but violates a declared constraint. */ + VALIDATION_FAILED, + + /** The caller is not permitted to send this. */ + NOT_AUTHORIZED, + + /** The credential that authorised the connection has expired. */ + CREDENTIAL_EXPIRED, + + /** The message or frame exceeds a declared bound. */ + TOO_LARGE, + + /** The caller is sending faster than its allowance. */ + RATE_LIMITED, + + /** The server has no capacity right now. */ + OVERLOADED, + + /** A command arrived after its deadline. */ + EXPIRED, + + /** Something failed that the platform cannot describe further without leaking. */ + INTERNAL; + + /** + * Whether this failure ends the connection. + * + *

Most do not. A malformed command is the client's bug in one message; disconnecting for it + * turns a typo into a reconnect storm, because the client retries the connection and sends the + * same bad message again. + */ + public boolean fatal() { + return this == CREDENTIAL_EXPIRED || this == TOO_LARGE || this == OVERLOADED; + } + + /** Whether the client should try the same thing again later. */ + public boolean retryable() { + return this == RATE_LIMITED || this == OVERLOADED || this == INTERNAL; + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/evidence/WebSocketConnectionEvidence.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/evidence/WebSocketConnectionEvidence.java new file mode 100644 index 00000000..2e2eae7d --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/evidence/WebSocketConnectionEvidence.java @@ -0,0 +1,67 @@ +package dev.caskeleton.adapter.inbound.websocket.evidence; + +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketConnectionId; +import dev.caskeleton.adapter.inbound.websocket.error.WebSocketCloseCode; +import java.time.Duration; +import java.time.Instant; +import java.util.Objects; +import java.util.Optional; + +/** + * What happened to one connection over its life. + * + *

The counters are the part that earns its place. A connection that closed normally after two + * hours and a connection that closed normally after two hours having dropped four thousand outbound + * messages look identical without them — and the second is a slow consumer that has been silently + * losing data the whole time. + * + * @param connectionId the connection + * @param openedAt when the handshake completed + * @param closedAt when it closed, if it has + * @param closeCode the code sent or received, if any + * @param inboundMessages how many inbound messages were admitted + * @param outboundMessages how many outbound messages were flushed + * @param droppedOutboundMessages how many were discarded because the peer could not keep up + * @param peakBufferedOutboundBytes the most unsent data held at once + */ +public record WebSocketConnectionEvidence( + WebSocketConnectionId connectionId, + Instant openedAt, + Optional closedAt, + Optional closeCode, + long inboundMessages, + long outboundMessages, + long droppedOutboundMessages, + long peakBufferedOutboundBytes) { + + public WebSocketConnectionEvidence { + Objects.requireNonNull(connectionId, "connectionId"); + Objects.requireNonNull(openedAt, "openedAt"); + Objects.requireNonNull(closedAt, "closedAt"); + Objects.requireNonNull(closeCode, "closeCode"); + if (closeCode.isPresent() && closedAt.isEmpty()) { + throw new IllegalArgumentException("a close code without a close time describes nothing"); + } + if (inboundMessages < 0 + || outboundMessages < 0 + || droppedOutboundMessages < 0 + || peakBufferedOutboundBytes < 0) { + throw new IllegalArgumentException("connection counters cannot be negative"); + } + } + + /** How long the connection was open, so far or in total. */ + public Duration lifetime(Instant now) { + return Duration.between(openedAt, closedAt.orElse(now)); + } + + /** + * Whether this connection lost outbound messages. + * + *

The signal a normal close hides. An operator reading close codes alone sees a clean + * disconnect; this is what says the peer was too slow and data was discarded to protect the node. + */ + public boolean lostMessages() { + return droppedOutboundMessages > 0; + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/evidence/WebSocketEvidenceSource.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/evidence/WebSocketEvidenceSource.java new file mode 100644 index 00000000..2888df0b --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/evidence/WebSocketEvidenceSource.java @@ -0,0 +1,26 @@ +package dev.caskeleton.adapter.inbound.websocket.evidence; + +/** + * Who observed a stage. + * + *

Recorded because the answer changes what the evidence is worth. A stage the transport saw is a + * fact about bytes; one the application reported is a fact about state. Only the application can + * say its work committed, and evidence that does not name its source lets an inference be read + * later as an observation. + */ +public enum WebSocketEvidenceSource { + + /** The container told us: a frame arrived, a write completed. */ + TRANSPORT, + + /** The platform's own code observed it: decode succeeded, admission passed. */ + PLATFORM, + + /** + * The application reported it. + * + *

The only source permitted to claim {@code APPLICATION_COMMITTED}. Nothing outside the + * application knows whether its transaction committed. + */ + APPLICATION +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/evidence/WebSocketInboundEvidence.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/evidence/WebSocketInboundEvidence.java new file mode 100644 index 00000000..13db3725 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/evidence/WebSocketInboundEvidence.java @@ -0,0 +1,121 @@ +package dev.caskeleton.adapter.inbound.websocket.evidence; + +import dev.caskeleton.adapter.inbound.websocket.protocol.WebSocketMessageId; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +/** + * What is actually known about one inbound message's progress. + * + *

Append-only and monotonic. The point is to be able to answer, after a crash or a lost + * connection, whether the work was durable — and an evidence record that could be rewritten or go + * backwards answers that with whatever was written last rather than with what happened. + * + *

{@code APPLICATION_COMMITTED} may only be recorded by the application. Nothing else knows. A + * handler returning normally means the method returned; it does not mean a transaction committed, + * and inferring one from the other is precisely how a retry re-executes a committed write. + */ +public final class WebSocketInboundEvidence { + + /** + * One observation. + * + * @param stage how far the message got + * @param source who observed it + * @param at when + */ + public record Observation( + WebSocketInboundStage stage, WebSocketEvidenceSource source, Instant at) { + + public Observation { + Objects.requireNonNull(stage, "stage"); + Objects.requireNonNull(source, "source"); + Objects.requireNonNull(at, "at"); + if (stage == WebSocketInboundStage.APPLICATION_COMMITTED + && source != WebSocketEvidenceSource.APPLICATION) { + // The rule the whole record exists for. Only the application knows whether its work is + // durable; anything else claiming it is an inference recorded as an observation. + throw new IllegalArgumentException( + "only the application may record APPLICATION_COMMITTED; " + + source + + " can observe that a handler returned, which is not the same thing"); + } + } + } + + private final WebSocketMessageId messageId; + private final List observations = new ArrayList<>(); + + /** + * Evidence for one message. + * + * @param messageId the message being tracked + */ + public WebSocketInboundEvidence(WebSocketMessageId messageId) { + this.messageId = Objects.requireNonNull(messageId, "messageId"); + } + + /** + * Records that a stage was reached. + * + * @throws IllegalStateException when the stage does not follow what is already recorded + */ + public synchronized WebSocketInboundEvidence record( + WebSocketInboundStage stage, WebSocketEvidenceSource source, Instant at) { + Observation observation = new Observation(stage, source, at); + WebSocketInboundStage latest = furthestStage().orElse(null); + if (!stage.mayFollow(latest)) { + throw new IllegalStateException( + "evidence for " + + messageId + + " already reached " + + latest + + "; recording " + + stage + + " would move it backwards, and evidence that can go backwards answers" + + " 'was this durable' with whatever was written last"); + } + observations.add(observation); + return this; + } + + /** The furthest stage reached. */ + public synchronized Optional furthestStage() { + return observations.stream() + .map(Observation::stage) + .max(java.util.Comparator.comparingInt(WebSocketInboundStage::rank)); + } + + /** + * Whether the message's work is known to be durable. + * + *

Known, not assumed. An unfinished message and a message whose handler returned without + * reporting a commit both answer false, because from the outside they are the same thing. + */ + public synchronized boolean durable() { + return furthestStage().map(WebSocketInboundStage::durable).orElse(false); + } + + /** + * Whether retrying this message is safe. + * + *

The question a reconnect has to answer. Safe exactly when nothing durable happened; anything + * else and the retry is a duplicate. + */ + public synchronized boolean safeToRetry() { + return !durable(); + } + + /** Every observation, in the order recorded. */ + public synchronized List observations() { + return List.copyOf(observations); + } + + /** The message being tracked. */ + public WebSocketMessageId messageId() { + return messageId; + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/evidence/WebSocketInboundStage.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/evidence/WebSocketInboundStage.java new file mode 100644 index 00000000..cc2817e6 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/evidence/WebSocketInboundStage.java @@ -0,0 +1,72 @@ +package dev.caskeleton.adapter.inbound.websocket.evidence; + +/** + * How far one inbound message actually got. + * + *

The distinction this exists for is {@link #APPLICATION_STARTED} versus {@link + * #APPLICATION_COMMITTED}. They are one step apart and mean opposite things when a connection dies + * between them: started-and-not-committed is safe to retry, and committed is not. A platform that + * records only "handled" cannot tell them apart, so after any crash every in-flight command is + * either retried — duplicating whatever committed — or abandoned, and there is no way to know which + * is correct. + * + *

The stages are ordered and progress is one-way, so evidence can be checked for coherence + * rather than trusted. + */ +public enum WebSocketInboundStage { + + /** The frame arrived. */ + FRAME_RECEIVED(1), + + /** Fragments were reassembled into a whole message. */ + MESSAGE_ASSEMBLED(2), + + /** The envelope parsed and its family rules held. */ + ENVELOPE_DECODED(3), + + /** The type is published and the caller may send it. */ + ADMITTED(4), + + /** The application was entered. Nothing durable has necessarily happened. */ + APPLICATION_STARTED(5), + + /** + * The application's work is durable. + * + *

Only ever recorded by the application itself, never inferred from the handler returning. A + * handler can return after a commit or after a rollback, and inferring from the return is how a + * retry re-executes a committed write. + */ + APPLICATION_COMMITTED(6), + + /** A response was handed to the outbound path. */ + RESPONSE_ENQUEUED(7); + + private final int rank; + + WebSocketInboundStage(int rank) { + this.rank = rank; + } + + /** + * Where this stage sits in the sequence. + * + *

Declared rather than taken from {@code ordinal()}. Declaration order is a source-file + * accident: inserting a stage in the middle silently renumbers every stage after it, and + * comparisons that were correct become wrong with no compile error and no test failure unless one + * happens to cover the pair that moved. + */ + public int rank() { + return rank; + } + + /** Whether this stage may follow {@code previous}. */ + public boolean mayFollow(WebSocketInboundStage previous) { + return previous == null || rank > previous.rank(); + } + + /** Whether reaching this stage means something durable happened. */ + public boolean durable() { + return rank >= APPLICATION_COMMITTED.rank(); + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/evidence/WebSocketOutboundEvidence.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/evidence/WebSocketOutboundEvidence.java new file mode 100644 index 00000000..f84c9c8d --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/evidence/WebSocketOutboundEvidence.java @@ -0,0 +1,92 @@ +package dev.caskeleton.adapter.inbound.websocket.evidence; + +import dev.caskeleton.adapter.inbound.websocket.protocol.WebSocketMessageId; +import java.time.Instant; +import java.util.Objects; +import java.util.Optional; + +/** + * How far one outbound message actually got. + * + *

Four states rather than sent/not-sent, because the two useful distinctions both live in the + * middle. {@code WRITTEN_TO_TRANSPORT} means the platform handed the bytes over — it does not mean + * they left the machine, and on a slow consumer they may sit in a buffer for a long time. {@code + * FLUSHED} means the transport says they did. Neither means the peer received them: a WebSocket has + * no application-level acknowledgement, so "delivered" is not knowable here at all and this type + * does not pretend otherwise. + * + *

That is why {@link #acknowledged} exists separately and is only ever set from a message the + * peer sent back. Treating a successful flush as delivery is how an at-least-once guarantee quietly + * becomes at-most-once during a network partition. + * + * @param messageId the message being tracked + * @param stage how far it got + * @param queuedAt when it entered the outbound queue + * @param flushedAt when the transport reported it flushed + * @param acknowledged when the peer confirmed it, if the protocol asked for confirmation + */ +public record WebSocketOutboundEvidence( + WebSocketMessageId messageId, + WebSocketOutboundStage stage, + Instant queuedAt, + Optional flushedAt, + Optional acknowledged) { + + public WebSocketOutboundEvidence { + Objects.requireNonNull(messageId, "messageId"); + Objects.requireNonNull(stage, "stage"); + Objects.requireNonNull(queuedAt, "queuedAt"); + Objects.requireNonNull(flushedAt, "flushedAt"); + Objects.requireNonNull(acknowledged, "acknowledged"); + if (stage == WebSocketOutboundStage.FLUSHED && flushedAt.isEmpty()) { + throw new IllegalArgumentException("a flushed message must say when it was flushed"); + } + if (acknowledged.isPresent() && flushedAt.isEmpty()) { + // An acknowledgement for something never written means the two are being tracked + // independently, and the pair has stopped describing one message. + throw new IllegalArgumentException( + "a message acknowledged but never flushed means the peer confirmed something this node" + + " never sent"); + } + } + + /** A message that has just entered the queue. */ + public static WebSocketOutboundEvidence queued(WebSocketMessageId messageId, Instant at) { + return new WebSocketOutboundEvidence( + messageId, WebSocketOutboundStage.QUEUED, at, Optional.empty(), Optional.empty()); + } + + /** The same message, handed to the transport. */ + public WebSocketOutboundEvidence written() { + return new WebSocketOutboundEvidence( + messageId, WebSocketOutboundStage.WRITTEN_TO_TRANSPORT, queuedAt, flushedAt, acknowledged); + } + + /** The same message, reported flushed by the transport. */ + public WebSocketOutboundEvidence flushed(Instant at) { + return new WebSocketOutboundEvidence( + messageId, WebSocketOutboundStage.FLUSHED, queuedAt, Optional.of(at), acknowledged); + } + + /** The same message, confirmed by the peer. */ + public WebSocketOutboundEvidence acknowledgedAt(Instant at) { + return new WebSocketOutboundEvidence( + messageId, WebSocketOutboundStage.FLUSHED, queuedAt, flushedAt, Optional.of(at)); + } + + /** The same message, dropped because the connection could not take it. */ + public WebSocketOutboundEvidence dropped() { + return new WebSocketOutboundEvidence( + messageId, WebSocketOutboundStage.DROPPED, queuedAt, flushedAt, acknowledged); + } + + /** + * Whether the peer is known to have received this. + * + *

Only true with an acknowledgement. A flush says the bytes left this process, which is a + * different claim and the one that is tempting to substitute. + */ + public boolean knownReceived() { + return acknowledged.isPresent(); + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/evidence/WebSocketOutboundStage.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/evidence/WebSocketOutboundStage.java new file mode 100644 index 00000000..f8b1701e --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/evidence/WebSocketOutboundStage.java @@ -0,0 +1,29 @@ +package dev.caskeleton.adapter.inbound.websocket.evidence; + +/** + * How far an outbound message got. + * + *

Note what is absent: there is no {@code DELIVERED}. A WebSocket gives the sender no + * application-level acknowledgement, so delivery is not observable from this side, and a stage + * named for it would be a claim the transport cannot support. + */ +public enum WebSocketOutboundStage { + + /** In the per-connection queue, not yet handed to the transport. */ + QUEUED, + + /** Handed to the transport. Possibly still sitting in a buffer. */ + WRITTEN_TO_TRANSPORT, + + /** The transport reports the bytes left this process. */ + FLUSHED, + + /** + * Discarded without being sent. + * + *

Recorded rather than silent, because a dropped message is the outcome an operator most needs + * to see: it means a consumer was too slow and the platform chose the connection's health over + * that message. + */ + DROPPED +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/handler/LateResponseTombstone.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/handler/LateResponseTombstone.java new file mode 100644 index 00000000..9fe24d08 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/handler/LateResponseTombstone.java @@ -0,0 +1,108 @@ +package dev.caskeleton.adapter.inbound.websocket.handler; + +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketConnectionId; +import dev.caskeleton.adapter.inbound.websocket.protocol.WebSocketCorrelationId; +import java.time.Duration; +import java.time.Instant; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Remembers correlation ids that have already timed out, so a late answer is recognised rather than + * re-attached. + * + *

This exists because of what happens without it. A request times out and its entry leaves the + * registry; the work it started is still running and eventually answers. If the client has since + * issued a new request that happened to reuse the correlation id — and clients that number their + * requests reuse ids constantly — the late answer matches the new entry and is delivered as the + * response to a question nobody asked it. Nothing errors, and the client believes it. + * + *

So a timed-out id is not forgotten immediately: it is tombstoned for a window, during which an + * arriving answer is classified as late and dropped, and a new request that tries to claim the same + * id is refused. The window is bounded because the tombstones are otherwise a map that grows with + * every timeout the connection ever had. + */ +public final class LateResponseTombstone { + + private final Duration window; + private final Map tombstones = new ConcurrentHashMap<>(); + + /** + * @param window how long a timed-out correlation id stays recognisable + */ + public LateResponseTombstone(Duration window) { + this.window = Objects.requireNonNull(window, "window"); + if (window.isNegative() || window.isZero()) { + throw new IllegalArgumentException( + "a zero tombstone window forgets a timed-out id immediately, which is exactly when the" + + " late answer for it is still in flight"); + } + } + + /** Record that a request timed out. */ + public void record( + WebSocketConnectionId connectionId, WebSocketCorrelationId correlationId, Instant at) { + Objects.requireNonNull(at, "at"); + tombstones.put(new Key(connectionId, correlationId), at); + } + + /** + * Whether an arriving answer is for a request that already timed out. + * + *

Dropping it is the only safe response. The caller has already been told the request timed + * out, so delivering the answer now would contradict that — and if the id has been reused, it + * would answer the wrong request. + */ + public boolean isLate( + WebSocketConnectionId connectionId, WebSocketCorrelationId correlationId, Instant now) { + Objects.requireNonNull(now, "now"); + Instant recorded = tombstones.get(new Key(connectionId, correlationId)); + return recorded != null && now.isBefore(recorded.plus(window)); + } + + /** + * Whether a new request may claim this correlation id. + * + *

Refused while the tombstone stands. Allowing the reuse is what lets a late answer attach to + * the new request. + */ + public boolean mayReuse( + WebSocketConnectionId connectionId, WebSocketCorrelationId correlationId, Instant now) { + return !isLate(connectionId, correlationId, now); + } + + /** + * Drop tombstones past their window. + * + * @return how many went + */ + public int evictExpired(Instant now) { + Objects.requireNonNull(now, "now"); + int before = tombstones.size(); + tombstones.values().removeIf(recorded -> !now.isBefore(recorded.plus(window))); + return before - tombstones.size(); + } + + /** Forget everything for a connection that has closed. */ + public int releaseConnection(WebSocketConnectionId connectionId) { + Objects.requireNonNull(connectionId, "connectionId"); + int before = tombstones.size(); + tombstones.keySet().removeIf(key -> key.connectionId().equals(connectionId)); + return before - tombstones.size(); + } + + /** How many tombstones are held. */ + public int size() { + return tombstones.size(); + } + + /** A tombstone is per connection: correlation ids are only unique within one. */ + private record Key(WebSocketConnectionId connectionId, WebSocketCorrelationId correlationId) { + + Key { + Objects.requireNonNull(connectionId, "connectionId"); + Objects.requireNonNull(correlationId, "correlationId"); + } + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/handler/WebSocketCorrelationRegistry.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/handler/WebSocketCorrelationRegistry.java new file mode 100644 index 00000000..041dea15 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/handler/WebSocketCorrelationRegistry.java @@ -0,0 +1,162 @@ +package dev.caskeleton.adapter.inbound.websocket.handler; + +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketConnectionId; +import dev.caskeleton.adapter.inbound.websocket.protocol.WebSocketCorrelationId; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Which requests are in flight on which connection, so answers reach the right waiter. + * + *

Two properties do the work, and both are about a failure mode HTTP does not have. + * + *

First, entries are scoped to a connection. A correlation id is chosen by the client, so two + * clients will eventually choose the same one — and a registry keyed on the id alone routes one + * caller's answer to the other. That is a disclosure, not a mix-up, and it happens more often than + * it sounds because clients commonly start their counters at 1. + * + *

Second, entries expire. A request whose answer never comes would otherwise occupy the registry + * for the life of the connection, and on a long-lived connection that is a leak measured in hours. + * Expiry also gives the platform something true to tell the waiter instead of leaving it blocked + * for ever. + */ +public final class WebSocketCorrelationRegistry { + + /** + * One in-flight request. + * + * @param connectionId which connection issued it + * @param correlationId what the client called it + * @param issuedAt when it was registered + * @param expiresAt when it stops being answerable + */ + public record InFlight( + WebSocketConnectionId connectionId, + WebSocketCorrelationId correlationId, + Instant issuedAt, + Instant expiresAt) { + + public InFlight { + Objects.requireNonNull(connectionId, "connectionId"); + Objects.requireNonNull(correlationId, "correlationId"); + Objects.requireNonNull(issuedAt, "issuedAt"); + Objects.requireNonNull(expiresAt, "expiresAt"); + } + + /** Whether this entry has aged out. */ + public boolean expiredAt(Instant now) { + return !now.isBefore(expiresAt); + } + } + + private record Key(WebSocketConnectionId connectionId, WebSocketCorrelationId correlationId) {} + + private final Map inFlight = new ConcurrentHashMap<>(); + private final int maxInFlightPerConnection; + + /** + * A registry with a per-connection cap. + * + * @param maxInFlightPerConnection how many requests one connection may have outstanding + */ + public WebSocketCorrelationRegistry(int maxInFlightPerConnection) { + if (maxInFlightPerConnection <= 0) { + throw new IllegalArgumentException("a registry that admits nothing answers nothing"); + } + this.maxInFlightPerConnection = maxInFlightPerConnection; + } + + /** + * Registers a request. + * + * @return false when the correlation is already in use on this connection, or the cap is reached + */ + public boolean register( + WebSocketConnectionId connectionId, + WebSocketCorrelationId correlationId, + Instant now, + Duration timeToLive) { + Objects.requireNonNull(connectionId, "connectionId"); + Objects.requireNonNull(correlationId, "correlationId"); + // Counted before inserting. Without a cap a client can register correlations faster than it + // consumes answers and the map is unbounded — the same slow-consumer shape as the outbound + // buffer, in a different structure. + if (countFor(connectionId, now) >= maxInFlightPerConnection) { + return false; + } + return inFlight.putIfAbsent( + new Key(connectionId, correlationId), + new InFlight(connectionId, correlationId, now, now.plus(timeToLive))) + == null; + } + + /** + * Takes the entry an answer belongs to, if it is still live. + * + *

Scoped to the connection, which is the point: an answer arriving on one connection can never + * resolve a request registered on another. + */ + public Optional complete( + WebSocketConnectionId connectionId, WebSocketCorrelationId correlationId, Instant now) { + Key key = new Key(connectionId, correlationId); + InFlight entry = inFlight.remove(key); + if (entry == null || entry.expiredAt(now)) { + return Optional.empty(); + } + return Optional.of(entry); + } + + /** How many live requests a connection has outstanding. */ + public int countFor(WebSocketConnectionId connectionId, Instant now) { + return (int) + inFlight.values().stream() + .filter(entry -> entry.connectionId().equals(connectionId)) + .filter(entry -> !entry.expiredAt(now)) + .count(); + } + + /** + * Removes and returns the entries that aged out. + * + *

Returned rather than dropped so the platform can tell each waiter something true. A silently + * discarded entry leaves the client waiting for an answer that will never come. + */ + public List reapExpired(Instant now) { + List expired = new ArrayList<>(); + inFlight.forEach( + (key, entry) -> { + if (entry.expiredAt(now) && inFlight.remove(key, entry)) { + expired.add(entry); + } + }); + return List.copyOf(expired); + } + + /** + * Removes everything a connection owns. + * + *

Called when the connection closes. Without it the registry keeps entries for connections + * that no longer exist, and the leak is proportional to churn rather than to load. + */ + public List releaseConnection(WebSocketConnectionId connectionId) { + List released = new ArrayList<>(); + inFlight.forEach( + (key, entry) -> { + if (key.connectionId().equals(connectionId) && inFlight.remove(key, entry)) { + released.add(entry); + } + }); + return List.copyOf(released); + } + + /** How many entries are held in total. */ + public int size() { + return inFlight.size(); + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/handler/WebSocketHandlerContext.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/handler/WebSocketHandlerContext.java new file mode 100644 index 00000000..27644e68 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/handler/WebSocketHandlerContext.java @@ -0,0 +1,63 @@ +package dev.caskeleton.adapter.inbound.websocket.handler; + +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketActorReference; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketConnectionId; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketEndpointName; +import dev.caskeleton.adapter.inbound.websocket.protocol.WebSocketMessageId; +import java.time.Instant; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; + +/** + * What a handler is allowed to know. + * + *

Everything here is a value. There is no session, no channel, no way to write and no way to + * close — deliberately, and it is the reason the platform's ordering and backpressure guarantees + * are guarantees rather than conventions. A handler that could write directly would bypass the + * outbound queue, and every promise about ordering would hold only for handlers that chose to + * cooperate. + * + *

The deadline is here because a handler needs it to make its own decisions — whether to start + * an expensive query at all — and because the platform cannot enforce a deadline on code that does + * not check one. Passing it makes cooperative cancellation possible; withholding it would make + * every timeout a hard abort. + * + * @param connectionId which connection this arrived on + * @param endpoint which endpoint it connected to + * @param actor a non-reversible reference to the caller + * @param authorities what the caller is permitted to do + * @param messageId the message being handled + * @param receivedAt when it arrived + * @param deadline when the platform stops waiting for this handler + */ +public record WebSocketHandlerContext( + WebSocketConnectionId connectionId, + WebSocketEndpointName endpoint, + WebSocketActorReference actor, + Set authorities, + WebSocketMessageId messageId, + Instant receivedAt, + Optional deadline) { + + public WebSocketHandlerContext { + Objects.requireNonNull(connectionId, "connectionId"); + Objects.requireNonNull(endpoint, "endpoint"); + Objects.requireNonNull(actor, "actor"); + Objects.requireNonNull(authorities, "authorities"); + Objects.requireNonNull(messageId, "messageId"); + Objects.requireNonNull(receivedAt, "receivedAt"); + Objects.requireNonNull(deadline, "deadline"); + authorities = Set.copyOf(authorities); + } + + /** Whether the handler has already run out of time. */ + public boolean expired(Instant now) { + return deadline.map(limit -> !now.isBefore(limit)).orElse(false); + } + + /** Whether the caller holds an authority. */ + public boolean holds(String authority) { + return authorities.contains(authority); + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/handler/WebSocketHandlerResult.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/handler/WebSocketHandlerResult.java new file mode 100644 index 00000000..86c52c73 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/handler/WebSocketHandlerResult.java @@ -0,0 +1,95 @@ +package dev.caskeleton.adapter.inbound.websocket.handler; + +import dev.caskeleton.adapter.inbound.websocket.error.WebSocketFailureCategory; +import dev.caskeleton.adapter.inbound.websocket.protocol.WebSocketMessageType; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +/** + * What a handler wants sent, described rather than sent. + * + *

Returned instead of written, so the platform stays the only thing that touches the socket. + * That is what makes ordering, backpressure and the drain sequence enforceable: a handler cannot + * jump the outbound queue because it has no way to reach past it. + * + *

Failure is a value here too, not an exception. An exception carries a stack trace and a + * message written for a developer, and turning one into a client-facing error is where internals + * leak. A handler that wants to fail says which category and what the peer may be told. + * + * @param emissions what to send, in order + * @param failure the failure to report instead, when the handler failed + */ +public record WebSocketHandlerResult(List emissions, Optional failure) { + + /** + * One message the handler wants sent. + * + * @param type the published type + * @param payload the document, to be encoded by the platform + */ + public record Emission(WebSocketMessageType type, Object payload) { + + public Emission { + Objects.requireNonNull(type, "type"); + Objects.requireNonNull(payload, "payload"); + } + } + + /** + * A failure the handler chose to report. + * + * @param category what a client can act on + * @param publishableDetail a message already safe to send + */ + public record HandlerFailure(WebSocketFailureCategory category, String publishableDetail) { + + public HandlerFailure { + Objects.requireNonNull(category, "category"); + Objects.requireNonNull(publishableDetail, "publishableDetail"); + if (publishableDetail.isBlank()) { + throw new IllegalArgumentException("a failure with no detail tells the client nothing"); + } + } + } + + public WebSocketHandlerResult { + Objects.requireNonNull(emissions, "emissions"); + Objects.requireNonNull(failure, "failure"); + emissions = List.copyOf(emissions); + if (failure.isPresent() && !emissions.isEmpty()) { + // A handler that both failed and produced output leaves the platform to choose which the + // client sees, and either choice is wrong for some caller. + throw new IllegalArgumentException( + "a handler either failed or produced output; both leaves the platform choosing what the" + + " client sees"); + } + } + + /** Nothing to send. */ + public static WebSocketHandlerResult none() { + return new WebSocketHandlerResult(List.of(), Optional.empty()); + } + + /** One message. */ + public static WebSocketHandlerResult of(WebSocketMessageType type, Object payload) { + return new WebSocketHandlerResult(List.of(new Emission(type, payload)), Optional.empty()); + } + + /** Several messages, in order. */ + public static WebSocketHandlerResult ofAll(List emissions) { + return new WebSocketHandlerResult(emissions, Optional.empty()); + } + + /** A failure the peer may be told about. */ + public static WebSocketHandlerResult failed( + WebSocketFailureCategory category, String publishableDetail) { + return new WebSocketHandlerResult( + List.of(), Optional.of(new HandlerFailure(category, publishableDetail))); + } + + /** Whether the handler reported a failure. */ + public boolean succeeded() { + return failure.isEmpty(); + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/handler/WebSocketMessageHandler.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/handler/WebSocketMessageHandler.java new file mode 100644 index 00000000..f853be3a --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/handler/WebSocketMessageHandler.java @@ -0,0 +1,39 @@ +package dev.caskeleton.adapter.inbound.websocket.handler; + +import dev.caskeleton.adapter.inbound.websocket.protocol.WebSocketMessageType; + +/** + * Where an application handles one decoded message. + * + *

The handler receives a decoded record and a context that carries no transport. That is the + * boundary the whole platform is arranged around: an application handler must not be able to write + * to the socket, close the connection, or read another connection's state. If it could, then + * ordering, backpressure and the drain sequence would each be advisory — any handler could bypass + * them by writing directly, and the platform's guarantees would hold only for the handlers that + * happened to cooperate. + * + *

So a handler returns what it wants sent and the platform decides when and whether. A handler + * that needs to emit more than one message returns a stream; one that needs to emit nothing returns + * nothing. + * + * @param the decoded command type this handler accepts + */ +@FunctionalInterface +public interface WebSocketMessageHandler { + + /** + * Handles one message. + * + * @param command the decoded request + * @param context what the handler may know about the connection + * @return what to send back, which the platform schedules + */ + WebSocketHandlerResult handle(C command, WebSocketHandlerContext context); + + /** The type this handler is registered for. */ + default WebSocketMessageType handledType() { + throw new UnsupportedOperationException( + "a handler must declare the type it is registered for; registration by reflection over a" + + " generic parameter is erased at runtime and silently binds the wrong handler"); + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/handshake/HandshakeAdmissionPipeline.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/handshake/HandshakeAdmissionPipeline.java new file mode 100644 index 00000000..4cd5ad37 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/handshake/HandshakeAdmissionPipeline.java @@ -0,0 +1,145 @@ +package dev.caskeleton.adapter.inbound.websocket.handshake; + +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketActorReference; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketEndpointCatalog; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketEndpointProfile; +import dev.caskeleton.adapter.inbound.websocket.protocol.WebSocketProtocolProfile; +import dev.caskeleton.adapter.inbound.websocket.security.WebSocketAuthenticationProfile; +import dev.caskeleton.adapter.inbound.websocket.security.WebSocketConnectionTicket; +import dev.caskeleton.adapter.inbound.websocket.security.WebSocketOriginPolicy; +import dev.caskeleton.adapter.inbound.websocket.security.WebSocketTicketStore; +import java.time.Instant; +import java.util.Objects; +import java.util.Optional; +import java.util.function.IntSupplier; + +/** + * Decides whether a handshake becomes a connection, in an order that is itself the contract. + * + *

The sequence matters more here than in an HTTP filter chain, because everything before the 101 + * is the last chance to refuse cheaply — after it, refusing costs a connection setup and a close + * frame, and the client has already been told it succeeded. + * + *

    + *
  1. Route. An unknown path is refused before anything else runs. Doing origin or + * credential work first spends effort on a request that was never going to be served, which + * is exactly what a scanner sends. + *
  2. Origin. Before authentication, because a cookie-authenticated handshake would + * otherwise authenticate successfully on a cross-origin request — the credential is valid; it + * is the page that is not allowed to use it. Checking after would mean the CSRF defence runs + * only for requests that already passed as legitimate. + *
  3. Capacity. Before the credential, because credential work is the expensive part — a + * ticket lookup or a token verification — and a saturated endpoint should shed before paying + * it. + *
  4. Credential. Last, and only for a request that is routed, permitted and affordable. + *
  5. Subprotocol. After the actor is known, so a refusal is attributable in the log. + *
+ */ +public final class HandshakeAdmissionPipeline { + + private final WebSocketEndpointCatalog endpoints; + private final WebSocketOriginPolicy originPolicy; + private final WebSocketAuthenticationProfile authenticationProfile; + private final WebSocketProtocolProfile protocolProfile; + private final WebSocketTicketStore ticketStore; + private final IntSupplier currentConnections; + + /** + * A pipeline for one endpoint set. + * + * @param endpoints which endpoints exist + * @param originPolicy which origins may connect + * @param authenticationProfile how a caller proves identity + * @param protocolProfile which subprotocols are spoken + * @param ticketStore where one-time tickets live + * @param currentConnections how many connections the endpoint currently holds + */ + public HandshakeAdmissionPipeline( + WebSocketEndpointCatalog endpoints, + WebSocketOriginPolicy originPolicy, + WebSocketAuthenticationProfile authenticationProfile, + WebSocketProtocolProfile protocolProfile, + WebSocketTicketStore ticketStore, + IntSupplier currentConnections) { + this.endpoints = Objects.requireNonNull(endpoints, "endpoints"); + this.originPolicy = Objects.requireNonNull(originPolicy, "originPolicy"); + this.authenticationProfile = + Objects.requireNonNull(authenticationProfile, "authenticationProfile"); + this.protocolProfile = Objects.requireNonNull(protocolProfile, "protocolProfile"); + this.ticketStore = Objects.requireNonNull(ticketStore, "ticketStore"); + this.currentConnections = Objects.requireNonNull(currentConnections, "currentConnections"); + } + + /** + * Admits or refuses one handshake. + * + * @param request the handshake, as values + * @param now the current instant + */ + public HandshakeDecision admit(HandshakeRequest request, Instant now) { + Objects.requireNonNull(request, "request"); + + Optional endpoint = endpoints.findByPath(request.path()); + if (endpoint.isEmpty()) { + return HandshakeDecision.refuse(404, "no endpoint is served at that path"); + } + WebSocketEndpointProfile profile = endpoint.get(); + + if (!originPolicy.permits(request.origin().orElse(null))) { + // Before authentication on purpose. A cookie-authenticated handshake from an attacker's page + // authenticates perfectly — the credential is valid and the page is not allowed to use it. + return HandshakeDecision.refuse(403, "this origin may not open a connection"); + } + + if (currentConnections.getAsInt() >= profile.maxConcurrentConnections()) { + // Before the credential, because credential work is the expensive part and a saturated + // endpoint should shed before paying for it. + return HandshakeDecision.refuse(503, "this endpoint is at its connection limit"); + } + + Optional actor = authenticate(request, profile, now); + if (actor.isEmpty()) { + return HandshakeDecision.refuse(401, "this handshake carries no usable credential"); + } + + if (!protocolProfile.acceptsHandshake(request.offeredSubprotocols())) { + // 400 rather than 426. The client did reach a WebSocket endpoint and did attempt an upgrade; + // what it offered is not something this endpoint speaks, and 426 would tell it to upgrade + // something it already upgraded. + return HandshakeDecision.refuse(400, "no offered subprotocol is supported here"); + } + + return HandshakeDecision.admit( + actor.get(), protocolProfile.negotiate(request.offeredSubprotocols())); + } + + private Optional authenticate( + HandshakeRequest request, WebSocketEndpointProfile profile, Instant now) { + if (!profile.requiresAuthentication()) { + return Optional.of(anonymousReference()); + } + return switch (authenticationProfile) { + case ONE_TIME_TICKET -> + request + .ticketValue() + // Redeemed, not read: the store consumes atomically, so two handshakes arriving with + // the same ticket cannot both succeed. + .flatMap(value -> ticketStore.redeem(value, now)) + .filter(ticket -> ticket.validAt(now)) + .filter(ticket -> ticket.endpoint().equals(profile.name())) + .map(WebSocketConnectionTicket::actor); + case SESSION_COOKIE -> + // The cookie's own verification belongs to the security integration; what admission + // decides is that one was attached and the origin was permitted. + request.sessionCookiePresent() ? Optional.of(anonymousReference()) : Optional.empty(); + case BEARER_HEADER -> + request.bearerToken().isPresent() ? Optional.of(anonymousReference()) : Optional.empty(); + }; + } + + private static WebSocketActorReference anonymousReference() { + // A placeholder the runtime replaces with the verified identity. Admission decides + // admissibility; it does not verify tokens, which is the security integration's job. + return new WebSocketActorReference("0".repeat(64)); + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/handshake/HandshakeDecision.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/handshake/HandshakeDecision.java new file mode 100644 index 00000000..e211a0cb --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/handshake/HandshakeDecision.java @@ -0,0 +1,61 @@ +package dev.caskeleton.adapter.inbound.websocket.handshake; + +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketActorReference; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketSubprotocolName; +import java.util.Objects; +import java.util.Optional; + +/** + * Whether a handshake is admitted, and what the connection is if so. + * + *

A refusal carries an HTTP status rather than a close code, and that is not a detail. Before + * the 101 there is no WebSocket: the only thing the client can be told is an HTTP response, and a + * server that tries to send a close frame here produces a browser error with no explanation + * attached. + * + * @param admitted whether the upgrade may proceed + * @param actor who is connecting, when admitted + * @param negotiatedSubprotocol what was agreed, when anything was + * @param refusalStatus the HTTP status to answer with, when refused + * @param refusalReason why, in terms safe to publish + */ +public record HandshakeDecision( + boolean admitted, + Optional actor, + Optional negotiatedSubprotocol, + int refusalStatus, + String refusalReason) { + + public HandshakeDecision { + Objects.requireNonNull(actor, "actor"); + Objects.requireNonNull(negotiatedSubprotocol, "negotiatedSubprotocol"); + Objects.requireNonNull(refusalReason, "refusalReason"); + if (admitted != actor.isPresent()) { + throw new IllegalArgumentException( + "an admitted handshake has an actor and a refused one does not; anything else means a" + + " connection is open with nobody attached to it"); + } + if (admitted && refusalStatus != 0) { + throw new IllegalArgumentException("an admitted handshake has no refusal status"); + } + if (!admitted && (refusalStatus < 400 || refusalStatus > 599)) { + throw new IllegalArgumentException("a refusal must carry a 4xx or 5xx status"); + } + } + + /** The handshake may proceed. */ + public static HandshakeDecision admit( + WebSocketActorReference actor, Optional subprotocol) { + return new HandshakeDecision(true, Optional.of(actor), subprotocol, 0, ""); + } + + /** + * The handshake is refused. + * + * @param status the HTTP status + * @param reason why, in terms safe to publish + */ + public static HandshakeDecision refuse(int status, String reason) { + return new HandshakeDecision(false, Optional.empty(), Optional.empty(), status, reason); + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/handshake/HandshakeRequest.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/handshake/HandshakeRequest.java new file mode 100644 index 00000000..31afb38d --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/handshake/HandshakeRequest.java @@ -0,0 +1,41 @@ +package dev.caskeleton.adapter.inbound.websocket.handshake; + +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketSubprotocolName; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +/** + * The handshake as the admission pipeline sees it: values, no transport. + * + *

A value type so admission is decided by pure code that runs identically under a servlet + * container and under Netty, and can be tested exhaustively without either. The two runtimes hand + * it different objects; they hand admission the same record. + * + * @param path the requested path + * @param origin the {@code Origin} header, absent when the client sent none + * @param offeredSubprotocols what the client offered, in its preference order + * @param ticketValue the ticket from the query string, when the profile uses one + * @param bearerToken the {@code Authorization} bearer, when the profile uses one + * @param sessionCookiePresent whether a session cookie was attached + * @param peerAddress the address the connection came from + */ +public record HandshakeRequest( + String path, + Optional origin, + List offeredSubprotocols, + Optional ticketValue, + Optional bearerToken, + boolean sessionCookiePresent, + String peerAddress) { + + public HandshakeRequest { + Objects.requireNonNull(path, "path"); + Objects.requireNonNull(origin, "origin"); + Objects.requireNonNull(offeredSubprotocols, "offeredSubprotocols"); + Objects.requireNonNull(ticketValue, "ticketValue"); + Objects.requireNonNull(bearerToken, "bearerToken"); + Objects.requireNonNull(peerAddress, "peerAddress"); + offeredSubprotocols = List.copyOf(offeredSubprotocols); + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/idempotency/CommandReconciliation.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/idempotency/CommandReconciliation.java new file mode 100644 index 00000000..2b1b8456 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/idempotency/CommandReconciliation.java @@ -0,0 +1,84 @@ +package dev.caskeleton.adapter.inbound.websocket.idempotency; + +import java.time.Instant; +import java.util.Objects; +import java.util.Optional; + +/** + * Resolves a command whose outcome was never recorded. + * + *

The window is small and unavoidable: the application commits, the process dies, the ledger + * entry is never written. What is left is a claim with no outcome, and neither replaying nor + * re-running is safe — replaying invents a result, re-running duplicates a committed write. + * + *

The only thing that can resolve it is the business data. Did the order exist? Was the payment + * captured? That question is application-specific, so the platform asks rather than guesses, and a + * deployment that cannot answer it says so — leaving the command unresolved is a worse outcome than + * a wrong guess only in the sense that it is visible. + */ +@FunctionalInterface +public interface CommandReconciliation { + + /** + * What the business data says about a command that started and was never resolved. + * + * @param key the command in question + * @param now the current instant + */ + Verdict reconcile(WebSocketCommandKey key, Instant now); + + /** + * The answer, and how confident it is. + * + * @param state what the business data shows + * @param encodedResult the result to replay, when the work is found to have committed + */ + record Verdict(State state, Optional encodedResult) { + + public Verdict { + Objects.requireNonNull(state, "state"); + Objects.requireNonNull(encodedResult, "encodedResult"); + if (state == State.COMMITTED && encodedResult.isEmpty()) { + throw new IllegalArgumentException( + "a command found to have committed must produce the result to replay, or the client" + + " is told it succeeded with nothing to show for it"); + } + if (state != State.COMMITTED && encodedResult.isPresent()) { + throw new IllegalArgumentException("only a committed verdict carries a result"); + } + } + + /** The business data shows the work happened. */ + public static Verdict committed(String encodedResult) { + return new Verdict(State.COMMITTED, Optional.of(encodedResult)); + } + + /** The business data shows the work did not happen. */ + public static Verdict notCommitted() { + return new Verdict(State.NOT_COMMITTED, Optional.empty()); + } + + /** The business data cannot say. */ + public static Verdict indeterminate() { + return new Verdict(State.INDETERMINATE, Optional.empty()); + } + } + + /** What reconciliation concluded. */ + enum State { + + /** The work is in the business data. Replay its result. */ + COMMITTED, + + /** The work is not there. The command may run again. */ + NOT_COMMITTED, + + /** + * The business data does not settle it. + * + *

Left unresolved rather than guessed. An operator can act on a command that says it does + * not know; nobody can act on one that quietly picked an answer. + */ + INDETERMINATE + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/idempotency/CommittedResultLedger.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/idempotency/CommittedResultLedger.java new file mode 100644 index 00000000..018d19c4 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/idempotency/CommittedResultLedger.java @@ -0,0 +1,61 @@ +package dev.caskeleton.adapter.inbound.websocket.idempotency; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; + +/** + * Where the outcome of a command is durably recorded. + * + *

An SPI, because the only implementation that actually works is one that writes in the same + * transaction as the application's own work. A ledger in memory, in a cache, or in a second + * database has a window between the business commit and the ledger write — and a crash inside that + * window produces exactly the {@link WebSocketCommandOutcome#UNKNOWN} state that no amount of + * retrying can resolve safely. + * + *

The platform therefore does not ship a default. A deployment that has not chosen a + * transactional ledger has not made command idempotency work, and a convenient in-memory default + * would hide that until the first crash. + */ +public interface CommittedResultLedger { + + /** + * Claims a command for execution. + * + *

Atomic. A claim implemented as read-then-write lets two attempts both see nothing and both + * run, which is the duplicate the ledger exists to prevent — and reconnect storms deliver exactly + * the concurrent duplicates that expose it. + * + * @param key what identifies the command + * @param now the current instant + * @param leaseDuration how long the claim holds before another attempt may take it + * @return the outcome, which is {@code NEW} when this caller won the claim + */ + WebSocketCommandOutcome claim( + WebSocketCommandKey key, Instant now, java.time.Duration leaseDuration); + + /** + * Records that the command committed, with the result to replay. + * + *

Must be called inside the application's own transaction. Called after it, the gap between + * the two is the UNKNOWN window. + */ + void recordCommitted(WebSocketCommandKey key, String encodedResult, Instant at); + + /** Records that the command failed without committing, so a retry may run. */ + void recordFailed(WebSocketCommandKey key, Instant at); + + /** The stored result for a committed command. */ + Optional committedResult(WebSocketCommandKey key, Instant now); + + /** The current outcome without claiming anything. */ + WebSocketCommandOutcome outcome(WebSocketCommandKey key, Instant now); + + /** + * Commands whose lease lapsed with no recorded outcome. + * + *

These are the UNKNOWN ones. Returned rather than resolved, because the ledger cannot know + * what happened — only the business data can say, and only a reconciliation that reads it. + */ + List unresolved(Instant now); +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/idempotency/WebSocketCommandKey.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/idempotency/WebSocketCommandKey.java new file mode 100644 index 00000000..7565b2c3 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/idempotency/WebSocketCommandKey.java @@ -0,0 +1,40 @@ +package dev.caskeleton.adapter.inbound.websocket.idempotency; + +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketActorReference; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketEndpointName; +import dev.caskeleton.adapter.inbound.websocket.protocol.WebSocketMessageId; +import java.util.Objects; + +/** + * What makes two command attempts the same command. + * + *

Deliberately not scoped to the connection, and that is the whole point. A WebSocket client + * that loses its connection reconnects and replays whatever it did not see an answer for — on a new + * connection, with a new connection id. A key that included the connection would treat every replay + * as a new command, which makes the idempotency machinery inert exactly when it is needed. + * + *

Scoped to the actor rather than the session for the same reason one step further out: a client + * that also lost its session identity still must not double-execute. The actor is the coarsest + * scope that is still safe, because two actors' commands can never collide. + * + * @param endpoint which endpoint the command arrived on + * @param actor who sent it + * @param messageId the client's own identifier for the command + */ +public record WebSocketCommandKey( + WebSocketEndpointName endpoint, WebSocketActorReference actor, WebSocketMessageId messageId) { + + public WebSocketCommandKey { + Objects.requireNonNull(endpoint, "endpoint"); + Objects.requireNonNull(actor, "actor"); + Objects.requireNonNull(messageId, "messageId"); + } + + /** A stable storage key, for a store that wants one string. */ + public String storageKey() { + // Separated by a unit separator, which cannot appear in any of the three components' grammars. + // Joining on a printable character would let one component's value forge a boundary and make + // two different commands share a key. + return endpoint.value() + '\u001f' + actor.fingerprint() + '\u001f' + messageId.value(); + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/idempotency/WebSocketCommandOutcome.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/idempotency/WebSocketCommandOutcome.java new file mode 100644 index 00000000..ffbb88db --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/idempotency/WebSocketCommandOutcome.java @@ -0,0 +1,33 @@ +package dev.caskeleton.adapter.inbound.websocket.idempotency; + +/** + * What is known about a command that was seen before. + * + *

Four answers rather than seen/not-seen, because each leads somewhere different. Only {@link + * #COMMITTED} may be answered with a stored result; {@link #IN_FLIGHT} must wait or refuse; {@link + * #FAILED} may be retried; and {@link #UNKNOWN} — a command that started and whose outcome was + * never recorded — is the one that must not be guessed. + */ +public enum WebSocketCommandOutcome { + + /** Never seen. Run it. */ + NEW, + + /** Another attempt is running right now. */ + IN_FLIGHT, + + /** It completed and its result is stored. Replay that. */ + COMMITTED, + + /** It ran and failed without committing. Safe to run again. */ + FAILED, + + /** + * It started and nothing recorded what happened. + * + *

The state a crash between the application's commit and the ledger write leaves behind. + * Neither replaying nor re-running is safe, so it is a state of its own rather than being folded + * into either — the platform's only correct move is to say so and let reconciliation decide. + */ + UNKNOWN +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/inbound/FragmentAssembler.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/inbound/FragmentAssembler.java new file mode 100644 index 00000000..84cccbb6 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/inbound/FragmentAssembler.java @@ -0,0 +1,117 @@ +package dev.caskeleton.adapter.inbound.websocket.inbound; + +import dev.caskeleton.adapter.inbound.websocket.budget.WebSocketConnectionBudget; +import dev.caskeleton.adapter.inbound.websocket.error.WebSocketFailureCategory; +import java.nio.charset.StandardCharsets; +import java.util.Objects; +import java.util.Optional; + +/** + * Reassembles a message from continuation frames, refusing before it costs anything. + * + *

Fragmentation is the inbound attack surface a WebSocket has and HTTP does not. A peer may send + * a first frame and then continuation frames indefinitely, and a naive assembler grows a buffer for + * as long as the peer keeps sending — no single frame is oversized, no error is thrown, and the + * memory is charged to a connection that looks healthy. + * + *

So three separate bounds apply, and each catches a shape the others do not: a frame bound + * refuses one huge frame, a total bound refuses many ordinary frames adding up, and a fragment + * count refuses an endless stream of tiny ones. The third is the one usually missing — a thousand + * one-byte continuations sit well inside both size bounds while costing a thousand array copies. + * + *

One assembler per connection. A shared one would interleave two peers' fragments into one + * message, which is both a corruption and a disclosure. + */ +public final class FragmentAssembler { + + private final WebSocketConnectionBudget budget; + private final StringBuilder buffer = new StringBuilder(); + private int fragments; + private boolean assembling; + + /** + * An assembler for one connection. + * + * @param budget the bounds to enforce + */ + public FragmentAssembler(WebSocketConnectionBudget budget) { + this.budget = Objects.requireNonNull(budget, "budget"); + } + + /** + * Accepts one frame. + * + * @param payload the frame's text + * @param finalFrame whether this frame completes the message + * @return the whole message when this frame completed it, else empty + * @throws FragmentAssemblyException when a bound is crossed or the sequence is invalid + */ + public Optional accept(String payload, boolean finalFrame) { + Objects.requireNonNull(payload, "payload"); + int frameBytes = payload.getBytes(StandardCharsets.UTF_8).length; + if (frameBytes > budget.maxFrameBytes()) { + reset(); + throw new FragmentAssemblyException( + WebSocketFailureCategory.TOO_LARGE, + "a frame exceeds " + budget.maxFrameBytes() + " bytes"); + } + + if (!assembling && finalFrame) { + // The common case: one unfragmented message. Nothing is buffered at all, so the ordinary + // path costs no copy. + return Optional.of(payload); + } + + fragments++; + if (fragments > budget.maxFragments()) { + reset(); + throw new FragmentAssemblyException( + WebSocketFailureCategory.TOO_LARGE, + "a message spans more than " + + budget.maxFragments() + + " frames; an endless stream of small continuations stays inside every size bound" + + " while costing a copy each"); + } + if (buffer.length() + payload.length() > budget.maxMessageBytes()) { + reset(); + throw new FragmentAssemblyException( + WebSocketFailureCategory.TOO_LARGE, + "a reassembled message exceeds " + budget.maxMessageBytes() + " bytes"); + } + + assembling = true; + buffer.append(payload); + if (!finalFrame) { + return Optional.empty(); + } + String message = buffer.toString(); + reset(); + return Optional.of(message); + } + + /** + * Discards any partial message. + * + *

Called when a connection closes or a control frame interrupts. Without it a half-assembled + * message holds its buffer for the life of the connection — which, for a connection that never + * sends again, is hours. + */ + public void reset() { + buffer.setLength(0); + // Trimmed as well as cleared: StringBuilder keeps its grown capacity, so a connection that + // once received a large message would hold that array for ever. + buffer.trimToSize(); + fragments = 0; + assembling = false; + } + + /** Whether a message is partially assembled. */ + public boolean assembling() { + return assembling; + } + + /** How many bytes are currently buffered. */ + public int bufferedLength() { + return buffer.length(); + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/inbound/FragmentAssemblyException.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/inbound/FragmentAssemblyException.java new file mode 100644 index 00000000..acb0b08a --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/inbound/FragmentAssemblyException.java @@ -0,0 +1,33 @@ +package dev.caskeleton.adapter.inbound.websocket.inbound; + +import dev.caskeleton.adapter.inbound.websocket.error.WebSocketFailureCategory; +import java.util.Objects; + +/** + * A frame sequence that could not be assembled within its bounds. + * + *

Carries a category, because the answer differs: an oversized message closes the connection + * with 1009, while a malformed sequence does not have to. + */ +public final class FragmentAssemblyException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + private final transient WebSocketFailureCategory category; + + /** + * An assembly failure. + * + * @param category what a client can act on + * @param publishableDetail a message safe to send + */ + public FragmentAssemblyException(WebSocketFailureCategory category, String publishableDetail) { + super(Objects.requireNonNull(publishableDetail, "publishableDetail")); + this.category = Objects.requireNonNull(category, "category"); + } + + /** What kind of failure this is. */ + public WebSocketFailureCategory category() { + return category; + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/lifecycle/CloseOrchestration.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/lifecycle/CloseOrchestration.java new file mode 100644 index 00000000..ef532ea0 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/lifecycle/CloseOrchestration.java @@ -0,0 +1,138 @@ +package dev.caskeleton.adapter.inbound.websocket.lifecycle; + +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketConnectionContext; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketConnectionState; +import dev.caskeleton.adapter.inbound.websocket.error.WebSocketCloseCode; +import java.time.Duration; +import java.time.Instant; +import java.util.Objects; +import java.util.Optional; + +/** + * The order a connection is taken down in, and why each step is where it is. + * + *

Closing a WebSocket is not one action. RFC 6455 has a closing handshake: the server sends a + * close frame, the peer answers, and only then is the TCP connection torn down. Skipping to the + * teardown produces a 1006 on the client — "closed abnormally, no reason given" — which is + * indistinguishable from a network failure and sends every client into its most aggressive + * reconnect path at exactly the moment the fleet is being restarted. + * + *

So the sequence is: stop accepting inbound, finish what is queued, send a close frame with a + * code that says why, wait briefly for the peer's answer, then tear down. Each step has a bound, + * because a peer that is gone will not complete any of them. + */ +public final class CloseOrchestration { + + /** How long the peer is given to answer a close frame before the socket is torn down. */ + public static final Duration CLOSE_HANDSHAKE_TIMEOUT = Duration.ofSeconds(5); + + /** What the platform should do next for one closing connection. */ + public enum Step { + + /** Move to DRAINING: no new inbound work, in-flight writes continue. */ + STOP_ACCEPTING_INBOUND, + + /** Keep writing what is already queued. */ + FLUSH_PENDING, + + /** Send the close frame with its code. */ + SEND_CLOSE_FRAME, + + /** Wait for the peer's close frame. */ + AWAIT_PEER_CLOSE, + + /** Tear down the socket. */ + TEAR_DOWN + } + + private CloseOrchestration() {} + + /** + * The next step for a connection being closed. + * + * @param context the connection + * @param pendingWrites how many messages are still queued + * @param closeFrameSentAt when the close frame went out, if it has + * @param peerClosed whether the peer answered + * @param now the current instant + * @param drainDeadline when the platform stops waiting for pending writes + */ + public static Step nextStep( + WebSocketConnectionContext context, + int pendingWrites, + Optional closeFrameSentAt, + boolean peerClosed, + Instant now, + Instant drainDeadline) { + Objects.requireNonNull(context, "context"); + Objects.requireNonNull(closeFrameSentAt, "closeFrameSentAt"); + + if (context.state() == WebSocketConnectionState.OPEN) { + return Step.STOP_ACCEPTING_INBOUND; + } + if (closeFrameSentAt.isEmpty()) { + // Bounded: a peer that stopped reading would otherwise hold the drain open for as long as it + // wants, and a rolling deploy waits behind it. + return pendingWrites > 0 && now.isBefore(drainDeadline) + ? Step.FLUSH_PENDING + : Step.SEND_CLOSE_FRAME; + } + if (peerClosed) { + return Step.TEAR_DOWN; + } + // Also bounded. A peer that is gone never answers, and waiting for it is how a shutdown hangs + // with no error to point at. + return now.isBefore(closeFrameSentAt.get().plus(CLOSE_HANDSHAKE_TIMEOUT)) + ? Step.AWAIT_PEER_CLOSE + : Step.TEAR_DOWN; + } + + /** + * The code to close with for a given reason. + * + * @param reason why the connection is being closed + */ + public static WebSocketCloseCode codeFor(CloseReason reason) { + Objects.requireNonNull(reason, "reason"); + return switch (reason) { + case CLIENT_REQUESTED -> WebSocketCloseCode.NORMAL; + // 1001, not 1000. A client that is told "going away" reconnects, and one told "normal" + // may reasonably conclude the session is over and not come back. + case NODE_SHUTTING_DOWN -> WebSocketCloseCode.GOING_AWAY; + case CREDENTIAL_EXPIRED -> WebSocketCloseCode.CREDENTIAL_EXPIRED; + case MAX_AGE_REACHED -> WebSocketCloseCode.MAX_AGE_REACHED; + case IDLE_TIMEOUT -> WebSocketCloseCode.IDLE_TIMEOUT; + case POLICY_VIOLATION -> WebSocketCloseCode.POLICY_VIOLATION; + case OUTBOUND_OVERFLOW -> WebSocketCloseCode.SHED_FOR_CAPACITY; + case INTERNAL_ERROR -> WebSocketCloseCode.INTERNAL_ERROR; + }; + } + + /** Why a connection is being closed. */ + public enum CloseReason { + + /** The client asked. */ + CLIENT_REQUESTED, + + /** This node is going away. */ + NODE_SHUTTING_DOWN, + + /** The credential expired past its grace. */ + CREDENTIAL_EXPIRED, + + /** The connection outlived its maximum age. */ + MAX_AGE_REACHED, + + /** The connection was silent past its idle timeout. */ + IDLE_TIMEOUT, + + /** The peer did something the platform will not tolerate. */ + POLICY_VIOLATION, + + /** A guaranteed message could not be queued. */ + OUTBOUND_OVERFLOW, + + /** Something failed that cannot be recovered for this connection. */ + INTERNAL_ERROR + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/lifecycle/HeartbeatPolicy.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/lifecycle/HeartbeatPolicy.java new file mode 100644 index 00000000..73919a74 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/lifecycle/HeartbeatPolicy.java @@ -0,0 +1,65 @@ +package dev.caskeleton.adapter.inbound.websocket.lifecycle; + +import java.time.Duration; +import java.time.Instant; +import java.util.Objects; + +/** + * How a connection is proven alive, and when it is given up on. + * + *

Heartbeats exist because TCP does not tell you the peer is gone. A laptop that closes its lid, + * a phone that switches to cellular, a NAT that forgets its mapping — none of these produce a FIN. + * The socket stays open and writable from the server's side, and the connection occupies its + * buffers, its registry entry and its subscription for as long as nobody checks. + * + *

Two intervals with a fixed relationship. The ping interval is how often the server asks; the + * idle timeout is how long silence is tolerated. A timeout shorter than two intervals is the + * classic misconfiguration: one dropped ping on a congested network closes a healthy connection, + * and the reconnect storm that follows makes the congestion worse. + * + * @param pingInterval how often the server sends a ping + * @param idleTimeout how long the server tolerates silence before closing + */ +public record HeartbeatPolicy(Duration pingInterval, Duration idleTimeout) { + + public HeartbeatPolicy { + Objects.requireNonNull(pingInterval, "pingInterval"); + Objects.requireNonNull(idleTimeout, "idleTimeout"); + if (pingInterval.isZero() || pingInterval.isNegative()) { + throw new IllegalArgumentException("a connection that is never pinged is never checked"); + } + if (idleTimeout.compareTo(pingInterval.multipliedBy(2)) < 0) { + throw new IllegalArgumentException( + "an idle timeout below two ping intervals closes a healthy connection on one dropped" + + " ping, and the reconnect storm makes the congestion that dropped it worse"); + } + if (pingInterval.compareTo(Duration.ofSeconds(20)) > 0) { + // Intermediaries commonly drop an idle connection at 30-60 seconds and send nothing to say + // so. A ping slower than that lets the platform's own liveness check be the thing that never + // notices. + throw new IllegalArgumentException( + "a ping interval over 20s outlives the idle timeout of common intermediaries, so the" + + " connection is dropped by something in the middle before the server checks"); + } + } + + /** The platform default. */ + public static HeartbeatPolicy standard() { + return new HeartbeatPolicy(Duration.ofSeconds(15), Duration.ofSeconds(45)); + } + + /** Whether a ping is due. */ + public boolean pingDue(Instant lastActivity, Instant now) { + return !now.isBefore(lastActivity.plus(pingInterval)); + } + + /** Whether the connection has been silent too long. */ + public boolean idleExpired(Instant lastActivity, Instant now) { + return !now.isBefore(lastActivity.plus(idleTimeout)); + } + + /** How many pings may go unanswered before the connection is closed. */ + public long toleratedMissedPings() { + return idleTimeout.toMillis() / pingInterval.toMillis(); + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/moduleboundary/WebSocketModuleBoundary.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/moduleboundary/WebSocketModuleBoundary.java new file mode 100644 index 00000000..cb408704 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/moduleboundary/WebSocketModuleBoundary.java @@ -0,0 +1,98 @@ +package dev.caskeleton.adapter.inbound.websocket.moduleboundary; + +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +/** + * Resolves a package name to the module that owns it. + * + *

Longest-prefix, so a sub-package belongs to its parent module without needing its own + * declaration. That is what lets {@code servlet.handshake} exist without loosening the boundary: it + * inherits {@code servlet}'s edge set rather than getting one of its own. + */ +public final class WebSocketModuleBoundary { + + /** The platform's root package. */ + public static final String PACKAGE_ROOT = deriveRootPackage(); + + private WebSocketModuleBoundary() {} + + /** Every declared module id. */ + public static Set allModuleIds() { + return WebSocketStableModule.moduleIds(); + } + + /** The declared edge set, by module id. */ + public static Map> dependencyEdges() { + return WebSocketStableModule.dependencyEdges(); + } + + /** The ids of the modules that may not name a framework type. */ + public static Set coreModuleIds() { + return WebSocketStableModule.coreModuleIds(); + } + + /** The package each module lives in, by module id. */ + public static Map packagesById() { + return WebSocketStableModule.packagesById(); + } + + /** + * The module that owns a package, by longest matching prefix. + * + * @param packageName a fully qualified package name + */ + public static Optional moduleIdForPackage(String packageName) { + if (packageName == null || !insidePlatform(packageName)) { + return Optional.empty(); + } + if (packageName.equals(PACKAGE_ROOT)) { + // The root package itself belongs to no module. The leaf's pre-existing STOMP transport + // classes live there, and claiming them for some module would give them an edge set nobody + // chose. + return Optional.empty(); + } + String relative = packageName.substring(PACKAGE_ROOT.length() + 1); + String bestId = null; + String bestPackage = null; + for (Map.Entry entry : packagesById().entrySet()) { + String candidate = entry.getValue(); + if (!matches(relative, candidate)) { + continue; + } + // Longest wins, so a module declared as a sub-package of another takes precedence over its + // parent rather than the iteration order deciding. + if (bestPackage == null || candidate.length() > bestPackage.length()) { + bestPackage = candidate; + bestId = entry.getKey(); + } + } + return Optional.ofNullable(bestId); + } + + /** Whether a package is inside the platform at all. */ + public static boolean insidePlatform(String packageName) { + return packageName != null + && (packageName.equals(PACKAGE_ROOT) || packageName.startsWith(PACKAGE_ROOT + ".")); + } + + /** Whether one module may import another. */ + public static boolean edgeAllowed(String from, String to) { + if (from == null || to == null || from.equals(to)) { + return true; + } + return dependencyEdges().getOrDefault(from, Set.of()).contains(to); + } + + private static boolean matches(String packageName, String candidate) { + return packageName.equals(candidate) || packageName.startsWith(candidate + "."); + } + + private static String deriveRootPackage() { + // Derived from this class rather than written out, so moving the platform cannot leave the + // boundary test scanning a package that no longer exists and reporting success. + String self = WebSocketModuleBoundary.class.getPackageName(); + return self.substring(0, self.lastIndexOf('.')); + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/moduleboundary/WebSocketModulePurity.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/moduleboundary/WebSocketModulePurity.java new file mode 100644 index 00000000..f633b87b --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/moduleboundary/WebSocketModulePurity.java @@ -0,0 +1,26 @@ +package dev.caskeleton.adapter.inbound.websocket.moduleboundary; + +/** + * Whether a module may name a framework type. + * + *

The distinction is load-bearing for this platform specifically. A WebSocket connection is a + * long-lived object owned by a container, and the temptation to reach for the container's own + * session type from anywhere is constant. A module marked CORE cannot: it has no {@code + * WebSocketSession}, no {@code ServerHttpRequest}, no {@code DataBuffer}, so the decisions it makes + * are testable without a server and portable between the two runtimes. + */ +public enum WebSocketModulePurity { + + /** + * Java standard library and this platform's own types only. + * + *

No Spring, no Servlet, no Reactor, no Netty. The design requires it of {@code + * websocket-core-api} and it is worth more here than in an HTTP platform: the same decision has + * to hold for a servlet session and a reactive one, and a module that can see neither cannot + * accidentally depend on one. + */ + CORE, + + /** May name framework types, because its job is to bind to one. */ + FRAMEWORK_BOUND +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/moduleboundary/WebSocketStableModule.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/moduleboundary/WebSocketStableModule.java new file mode 100644 index 00000000..94600419 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/moduleboundary/WebSocketStableModule.java @@ -0,0 +1,394 @@ +package dev.caskeleton.adapter.inbound.websocket.moduleboundary; + +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.stream.Collectors; + +/** + * The Stable WebSocket platform modules, their purity grade and their allowed internal edges. + * + *

The design models this platform as eighteen Gradle modules under {@code modules/websocket}. + * This repository's fail-closed registry owns the leaf list, so they are sub-packages of one + * registered leaf instead — the same resolution the JPA, GraphQL and web platforms reached. The + * mapping is in {@code docs/websocket/repository-adaptation.md}. + * + *

That choice is only honest if the boundaries are machine checked, so this enum is the declared + * identity and {@code WebSocketModuleBoundaryTest} fails when the source tree and this declaration + * disagree in either direction — an undeclared edge, or a package with no declared identity. + * + *

The edge that matters most is the one that is absent: {@code servlet} and {@code webflux} + * declare no edge to each other. The two runtimes are mutually exclusive, and an edge between them + * would let the reactive runtime compile against a servlet type — which is how a deployment ends up + * shipping both and starting neither. + */ +public enum WebSocketStableModule { + + /** + * Identifiers, endpoint names, connection state and the immutable connection context. + * + *

The design's {@code websocket-core-api}. CORE, and the strictest instance of it in this + * repository: a connection identifier that could hold a {@code WebSocketSession} would make every + * downstream decision untestable without a running container. + */ + CORE("core", "core", WebSocketModulePurity.CORE, "budget"), + + /** Protocol names, versions, message families, envelopes and the wire codec profile. */ + PROTOCOL("protocol", "protocol", WebSocketModulePurity.CORE, "core"), + + /** + * Frame, message and connection resource budgets. + * + *

A leaf: it names no other module. The declared direction was {@code budget -> core} until + * the endpoint profile — which is core — had to name a budget, and the boundary test refused the + * cycle. Numbers depend on nothing, so {@code core -> budget} is the coherent direction and the + * one that was actually true all along. + */ + BUDGET("budget", "budget", WebSocketModulePurity.CORE), + + /** + * The strict wire codec and the manifest of what it will decode. + * + *

FRAMEWORK_BOUND, and separate from {@code protocol} because of it. The design puts the codec + * in {@code websocket-protocol}; here that module is CORE, and a Jackson import in a CORE module + * is refused. Splitting them is better than relaxing the rule: the envelope's field rules stay + * testable with no mapper, and everything that touches a parser sits in one package a reviewer + * can read end to end. + */ + CODEC( + "codec", + "codec", + WebSocketModulePurity.FRAMEWORK_BOUND, + "budget", + "core", + "error", + "protocol"), + + /** Typed errors and the close-code catalog. */ + ERROR("error", "error", WebSocketModulePurity.CORE, "core", "protocol"), + + /** + * The three-axis execution evidence model: inbound, outbound and connection. + * + *

Names {@code error} because connection evidence records the close code the connection ended + * with. That is the field that separates "closed cleanly" from "closed because the peer could not + * keep up", and without it the two are indistinguishable in the record. + */ + EVIDENCE("evidence", "evidence", WebSocketModulePurity.CORE, "core", "error", "protocol"), + + /** The application message handler boundary and correlation registry. */ + HANDLER("handler", "handler", WebSocketModulePurity.CORE, "core", "error", "protocol"), + + /** Command idempotency and the committed-result ledger. */ + IDEMPOTENCY("idempotency", "idempotency", WebSocketModulePurity.CORE, "core", "protocol"), + + /** Authentication profiles, origin policy and one-time connection tickets. */ + SECURITY("security", "security", WebSocketModulePurity.CORE, "core", "error"), + + /** The handshake admission pipeline. */ + /** + * The handshake admission pipeline. + * + *

Names {@code protocol} because subprotocol negotiation is part of admission: it is the last + * decision made before the 101 and the only one a client can still be told about over HTTP. + */ + HANDSHAKE( + "handshake", + "handshake", + WebSocketModulePurity.CORE, + "budget", + "core", + "error", + "protocol", + "security"), + + /** Node-local session registry and per-connection state. */ + SESSION("session", "session", WebSocketModulePurity.CORE, "budget", "core", "evidence"), + + /** Message authorization, applied per message rather than per connection. */ + AUTHZ("authz", "authz", WebSocketModulePurity.CORE, "core", "error", "protocol", "security"), + + /** + * Inbound frame reassembly and its bounds. + * + *

Separate from {@code protocol} because it is about frames rather than about messages: it + * runs before anything has parsed, on bytes chosen entirely by the peer, and its whole job is to + * refuse before the parse is paid for. + */ + INBOUND("inbound", "inbound", WebSocketModulePurity.CORE, "budget", "core", "error"), + + /** Outbound delivery semantics, priority, queueing and backpressure. */ + OUTBOUND( + "outbound", "outbound", WebSocketModulePurity.CORE, "budget", "core", "error", "protocol"), + + /** Ordering profiles, sequencing and gap detection. */ + ORDERING("ordering", "ordering", WebSocketModulePurity.CORE, "core", "protocol"), + + /** Heartbeats, idle policy, credential expiry and connection age. */ + LIFECYCLE( + "lifecycle", "lifecycle", WebSocketModulePurity.CORE, "core", "error", "security", "session"), + + /** Metrics, tracing and safe logging for a long-lived connection. */ + OBSERVABILITY( + "observability", "observability", WebSocketModulePurity.CORE, "core", "error", "evidence"), + + /** The operator's view: snapshot, forced disconnect and drain. */ + ADMIN("admin", "admin", WebSocketModulePurity.CORE, "core", "lifecycle", "session"), + + /** + * The servlet runtime. + * + *

No edge to {@code webflux}: the two runtimes are mutually exclusive and an edge would let + * one compile against the other's session type. + */ + SERVLET( + "servlet", + "servlet", + WebSocketModulePurity.FRAMEWORK_BOUND, + "authz", + "budget", + "core", + "error", + "evidence", + "handler", + "inbound", + "handshake", + "lifecycle", + "observability", + "ordering", + "outbound", + "protocol", + "security", + "session"), + + /** The reactive runtime, with the same allowed edges and no edge to {@code servlet}. */ + WEBFLUX( + "webflux", + "webflux", + WebSocketModulePurity.FRAMEWORK_BOUND, + "authz", + "budget", + "core", + "error", + "evidence", + "handler", + "inbound", + "handshake", + "lifecycle", + "observability", + "ordering", + "outbound", + "protocol", + "security", + "session"), + + /** Bound configuration properties and the startup validator. */ + /** + * Bound configuration and the startup validator. + * + *

Names nearly everything, and that is inherent to what it does: the validator's job is to + * refuse an incoherent combination of the platform's parts, so it has to be able to see them. + */ + CONFIG( + "config", + "config", + WebSocketModulePurity.FRAMEWORK_BOUND, + "authz", + "budget", + "core", + "lifecycle", + "outbound", + "protocol", + "security"), + + /** + * The Advanced capabilities, each behind its own feature flag. + * + *

One module rather than the design's fifteen because they share exactly one property — none + * is on unless named — and nothing else. The flag enum keeps them independent of each other; the + * boundary rule that matters is the one ArchUnit enforces, that no Stable module may name this + * package. A flag decides whether a bean is created and does nothing about a compile-time edge. + */ + ADVANCED( + "advanced", + "advanced", + WebSocketModulePurity.CORE, + "core", + "error", + "evidence", + "protocol", + "security", + "session"), + + /** + * The Advanced STOMP subprotocol adapter. + * + *

Its own module rather than part of {@code advanced} because it is the one Advanced + * capability that cannot be pure: STOMP is Spring Messaging's protocol here, and the adapter is + * the Spring types. Folding it into {@code advanced} would mean relaxing that module's purity for + * every capability in it, so the framework dependency is fenced to the package that needs it. + */ + ADVANCED_STOMP( + "advanced-stomp", + "advanced.stomp", + WebSocketModulePurity.FRAMEWORK_BOUND, + "advanced", + "core", + "error", + "evidence", + "security"), + + /** + * The Advanced binary codecs, CBOR and Protobuf. + * + *

{@code FRAMEWORK_BOUND} because a codec is its format's library — Jackson's CBOR backend and + * protobuf's own runtime. It names the Stable {@code codec} module deliberately: the wire type + * manifest and the decode exception are shared, so a second decode path resolves against the same + * manifest and reports failures in the same vocabulary as the first. Duplicating either would let + * the two codecs publish different type sets, which is exactly what {@code SchemaParity} exists + * to refuse. + */ + ADVANCED_CODEC( + "advanced-codec", + "advanced.codec", + WebSocketModulePurity.FRAMEWORK_BOUND, + "advanced", + "budget", + "codec", + "error", + "protocol"), + + /** + * The SockJS fallback endpoint. + * + *

Its own module because registering a fallback endpoint means holding Spring's WebSocket + * configuration, and folding that into {@code advanced} would relax purity for every capability + * in it. The profile that decides which transports are offered stays pure and lives there; only + * the registration is here. + */ + ADVANCED_SOCKJS( + "advanced-sockjs", + "advanced.sockjs", + WebSocketModulePurity.FRAMEWORK_BOUND, + "advanced", + "core", + "security"), + + /** + * The RabbitMQ broker relay and the multi-node user destination that depends on it. + * + *

Separated from {@code advanced-stomp} because it is a different operational decision with a + * different blast radius. The adapter parses a protocol; the relay opens a TCP connection to + * somebody else's broker and makes every delivery depend on it staying up. + */ + ADVANCED_STOMP_RABBIT( + "advanced-stomp-rabbit", + "advanced.stomp.rabbit", + WebSocketModulePurity.FRAMEWORK_BOUND, + "advanced", + "advanced-stomp", + "core", + "security"), + + /** + * The pre-existing STOMP-over-SockJS live-push channel. + * + *

Not part of the design's platform, and declared here so the boundary is complete rather than + * excused. The design specifies a raw WebSocket platform; this is separate, older machinery that + * predates it and still ships. + * + *

It declares no edge to any platform module, and no platform module declares an edge to it. + * That is the point of listing it: the two are independent, and the enum is where somebody who + * later wires them together has to say so. It is also why the Advanced adapter above does not + * extend it — see {@code StompBrokerExclusivity} for why only one of the two may run. + */ + STOMP("stomp", "stomp", WebSocketModulePurity.FRAMEWORK_BOUND), + + /** + * What the Stable platform must show before it is promoted, and the two scenarios that show it. + * + *

CORE, and a leaf: it names no other module. That is deliberate rather than incidental. A + * gate that imported the parts it gates would be satisfiable by construction — the evidence would + * be whatever the platform happens to produce, checked against a list derived from the same + * source. These are predicates over facts a release engineer supplies, so a missing runtime is a + * missing runtime and not a module that failed to register itself. + */ + RELEASE("release", "release", WebSocketModulePurity.CORE), + + /** The declared module map and its machine-checked identity. */ + MODULE_BOUNDARY("moduleboundary", "moduleboundary", WebSocketModulePurity.CORE); + + private final String id; + private final String packageName; + private final WebSocketModulePurity purity; + + /** + * Populated only from {@link Set#of}, which is genuinely immutable. Error Prone's {@code + * ImmutableEnumChecker} recognises Guava's {@code ImmutableSet} but not the JDK's unmodifiable + * factories, and this leaf has no Guava dependency to add for one field. The same suppression, + * for the same reason, sits on {@code WebStableModule}. + */ + @SuppressWarnings("ImmutableEnumChecker") + private final Set allowedEdges; + + WebSocketStableModule( + String id, String packageName, WebSocketModulePurity purity, String... allowedEdges) { + this.id = id; + this.packageName = packageName; + this.purity = purity; + this.allowedEdges = Set.of(allowedEdges); + } + + /** The module's identity. */ + public String id() { + return id; + } + + /** The package that carries it, relative to the platform root. */ + public String packageName() { + return packageName; + } + + /** Whether it may name a framework type. */ + public WebSocketModulePurity purity() { + return purity; + } + + /** The modules it may import. */ + public Set allowedEdges() { + return allowedEdges; + } + + /** Every declared module id. */ + public static Set moduleIds() { + return Arrays.stream(values()).map(WebSocketStableModule::id).collect(Collectors.toSet()); + } + + /** The ids of the modules that may not name a framework type. */ + public static Set coreModuleIds() { + return Arrays.stream(values()) + .filter(module -> module.purity() == WebSocketModulePurity.CORE) + .map(WebSocketStableModule::id) + .collect(Collectors.toSet()); + } + + /** The declared edge set, by module id. */ + public static Map> dependencyEdges() { + Map> edges = new TreeMap<>(); + for (WebSocketStableModule module : values()) { + edges.put(module.id(), module.allowedEdges()); + } + return Map.copyOf(edges); + } + + /** The package each module lives in, by module id. */ + public static Map packagesById() { + Map packages = new LinkedHashMap<>(); + for (WebSocketStableModule module : values()) { + packages.put(module.id(), module.packageName()); + } + return Map.copyOf(packages); + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/observability/SafeWebSocketLogFields.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/observability/SafeWebSocketLogFields.java new file mode 100644 index 00000000..dc2735e9 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/observability/SafeWebSocketLogFields.java @@ -0,0 +1,65 @@ +package dev.caskeleton.adapter.inbound.websocket.observability; + +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketConnectionContext; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + +/** + * What may be written about a connection, and nothing else. + * + *

Log volume is the part that surprises people. An HTTP request logs once; a connection can log + * on every message for hours, so a field that is merely unwise in a request log is written + * thousands of times here, into a store with a retention policy nobody chose for it. + * + *

So the payload is never logged — not truncated, not redacted, not at debug. A payload log + * behind a debug flag is a payload log the first time somebody debugs a production incident, and + * that is exactly when the data is most sensitive and the flag most likely to be left on. + * + *

The actor appears only as its short fingerprint. That is enough to correlate two connections + * as the same caller while reading, and not enough to name them. + */ +public final class SafeWebSocketLogFields { + + private SafeWebSocketLogFields() {} + + /** + * The fields describing one connection. + * + * @param context the connection + */ + public static Map of(WebSocketConnectionContext context) { + Objects.requireNonNull(context, "context"); + Map fields = new LinkedHashMap<>(); + fields.put("connectionId", context.connectionId().value()); + fields.put("endpoint", context.endpoint().value()); + fields.put("nodeId", context.nodeId().value()); + fields.put("state", context.state().name()); + // Short form, not the whole digest: enough to correlate while reading, and obviously not the + // identity itself. + fields.put("actor", context.actor().shortForm()); + context.subprotocol().ifPresent(name -> fields.put("subprotocol", name.value())); + return Map.copyOf(fields); + } + + /** + * Whether a field name is safe to log for a connection. + * + *

Used by a review check rather than at runtime. The runtime path builds its fields from + * {@link #of}, so there is nothing to filter; this exists so a new log statement elsewhere can be + * checked against the same rule. + */ + public static boolean safeFieldName(String name) { + if (name == null) { + return false; + } + String lower = name.toLowerCase(java.util.Locale.ROOT); + return !(lower.contains("payload") + || lower.contains("body") + || lower.contains("token") + || lower.contains("ticket") + || lower.contains("cookie") + || lower.contains("credential") + || lower.contains("authorization")); + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/observability/WebSocketMetricTags.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/observability/WebSocketMetricTags.java new file mode 100644 index 00000000..bb6747db --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/observability/WebSocketMetricTags.java @@ -0,0 +1,76 @@ +package dev.caskeleton.adapter.inbound.websocket.observability; + +import java.util.Locale; +import java.util.Set; + +/** + * Which tags a WebSocket metric may carry. + * + *

An allowlist, and the cardinality risk here is worse than for HTTP. A request produces one + * observation and is gone; a connection produces observations for hours, and a tag carrying the + * connection id turns one time series into one per connection that ever existed — the series + * outlive the connections, so the count only ever grows. + * + *

{@code nodeId} is on the list and the connection and session identifiers are not, for exactly + * that reason: a fleet has a knowable number of nodes and an unbounded number of connections. + */ +public final class WebSocketMetricTags { + + /** The complete set of tags a WebSocket metric may carry. */ + private static final Set ALLOWED = + Set.of( + "endpoint", + "nodeId", + "subprotocol", + "closeCode", + "failureCategory", + "messageType", + "direction", + "outcome"); + + private WebSocketMetricTags() {} + + /** Whether a tag may be recorded. */ + public static boolean allowed(String name) { + return name != null && ALLOWED.contains(name); + } + + /** The complete allowlist. */ + public static Set allowedTags() { + return ALLOWED; + } + + /** + * Refuses a tag that is not on the allowlist. + * + * @throws IllegalArgumentException when it is not allowed + */ + public static void require(String name) { + if (!allowed(name)) { + throw new IllegalArgumentException( + "metric tag '" + + name + + "' is not on the allowlist " + + ALLOWED + + "; a connection produces observations for hours, so an unbounded tag creates" + + " series that outlive the connections and never stop accumulating"); + } + } + + /** Whether a name is one that must never be a tag. */ + public static boolean obviouslyUnbounded(String name) { + if (name == null) { + return false; + } + String lower = name.toLowerCase(Locale.ROOT); + return lower.contains("connection") + || lower.contains("session") + || lower.contains("actor") + || lower.contains("user") + || lower.contains("tenant") + || lower.contains("correlation") + || lower.contains("stream") + || lower.contains("payload") + || lower.contains("ticket"); + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/ordering/GapDetector.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/ordering/GapDetector.java new file mode 100644 index 00000000..f8c85677 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/ordering/GapDetector.java @@ -0,0 +1,98 @@ +package dev.caskeleton.adapter.inbound.websocket.ordering; + +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Notices when a stream skipped, reordered or repeated a position. + * + *

Server-side, and that is deliberate. Detecting a gap on the client tells the client something + * went wrong; detecting it here tells the operator, and the operator is the one who can fix it. A + * conflating queue, a dropped droppable message and a genuine bug all look identical from the + * client's side, and only the server knows which happened. + * + *

The three outcomes are distinguished because they mean different things. {@link + * Observation#GAP} under a lossy profile is expected and under a total-order profile is a defect; + * {@link Observation#REORDERED} means something wrote out of order, which no profile permits; + * {@link Observation#DUPLICATE} means a retry was not deduplicated. + */ +public final class GapDetector { + + /** What a sequence number turned out to be. */ + public enum Observation { + + /** Exactly the next position. */ + IN_ORDER, + + /** Ahead of the next position: something in between was not delivered. */ + GAP, + + /** Behind the last position: something arrived out of order. */ + REORDERED, + + /** The same position twice. */ + DUPLICATE + } + + private final Map lastSeen = new ConcurrentHashMap<>(); + + /** + * Records a position and says what it was. + * + * @param streamId which stream + * @param sequence the position observed + */ + public Observation observe(String streamId, long sequence) { + Objects.requireNonNull(streamId, "streamId"); + // Computed atomically. A read-then-write would let two observations on one stream interleave + // and report a gap that is really a race in the detector. + Observation[] result = new Observation[1]; + lastSeen.compute( + streamId, + (id, previous) -> { + if (previous == null) { + result[0] = Observation.IN_ORDER; + return sequence; + } + if (sequence == previous + 1) { + result[0] = Observation.IN_ORDER; + return sequence; + } + if (sequence == previous) { + result[0] = Observation.DUPLICATE; + return previous; + } + if (sequence < previous) { + result[0] = Observation.REORDERED; + // The high-water mark is kept, not lowered. Lowering it would report every subsequent + // in-order message as a duplicate. + return previous; + } + result[0] = Observation.GAP; + return sequence; + }); + return result[0]; + } + + /** + * How many positions were skipped, for a gap. + * + * @param streamId which stream + * @param sequence the position observed + */ + public long gapSize(String streamId, long sequence) { + Long previous = lastSeen.get(streamId); + return previous == null || sequence <= previous ? 0 : sequence - previous - 1; + } + + /** The last position seen on a stream. */ + public long lastSeen(String streamId) { + return lastSeen.getOrDefault(streamId, 0L); + } + + /** Forgets a stream, so the map does not grow with churn. */ + public void forget(String streamId) { + lastSeen.remove(streamId); + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/ordering/OrderingProfile.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/ordering/OrderingProfile.java new file mode 100644 index 00000000..1cdfab35 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/ordering/OrderingProfile.java @@ -0,0 +1,54 @@ +package dev.caskeleton.adapter.inbound.websocket.ordering; + +/** + * What ordering a stream actually promises. + * + *

Declared per stream rather than assumed, because a WebSocket makes the strongest guarantee + * look free and it is not. The transport delivers frames on one connection in order, so a naive + * reading is that everything is ordered — but a reconnect starts a new connection, and anything the + * server produced concurrently was never ordered to begin with. A client that assumed total order + * is wrong at exactly the moment it reconnects, which is the moment it is least able to notice. + * + *

Saying which promise applies also tells the client what to do about a gap, and the answers are + * different: under {@link #PER_STREAM_TOTAL} a gap means resynchronise, under {@link #BEST_EFFORT} + * it means nothing at all. + */ +public enum OrderingProfile { + + /** + * No ordering promise. + * + *

Right for independent notifications. A client must not gap-detect on these, and this profile + * exists so it can tell. + */ + BEST_EFFORT, + + /** + * Every message on one stream arrives once, in order, with no gaps. + * + *

The strongest promise this platform makes, and it is per stream rather than per connection. + * Across a reconnect it holds only if the client resumes from its last sequence, which is what + * the sequence number is for. + */ + PER_STREAM_TOTAL, + + /** + * In order, but messages may be skipped. + * + *

The honest profile for a conflated stream: the client always moves forward and never sees an + * older value after a newer one, but it will not see every value. Without this as a distinct + * profile a conflated stream has to claim either total order — which conflation breaks — or best + * effort, which understates what it does give. + */ + MONOTONIC_LOSSY; + + /** Whether a client should treat a missing sequence as a problem. */ + public boolean gapsAreErrors() { + return this == PER_STREAM_TOTAL; + } + + /** Whether a client may rely on never seeing an older message after a newer one. */ + public boolean monotonic() { + return this != BEST_EFFORT; + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/ordering/StreamSequencer.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/ordering/StreamSequencer.java new file mode 100644 index 00000000..c814f6a2 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/ordering/StreamSequencer.java @@ -0,0 +1,54 @@ +package dev.caskeleton.adapter.inbound.websocket.ordering; + +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; + +/** + * Assigns sequence numbers, one counter per stream. + * + *

Per stream rather than per connection, because the number is what a client resumes from after + * a reconnect. A connection-scoped counter restarts at zero on the new connection, and the client's + * "resume from 4210" then either replays from the beginning or is rejected — neither of which is a + * resume. + * + *

Sequences start at 1. Zero is the value an uninitialised field has, so starting there makes + * "never sent anything" and "sent the first message" the same observation on the client's side. + */ +public final class StreamSequencer { + + private final Map counters = new ConcurrentHashMap<>(); + + /** + * The next position on a stream. + * + * @param streamId which stream + */ + public long next(String streamId) { + Objects.requireNonNull(streamId, "streamId"); + return counters.computeIfAbsent(streamId, id -> new AtomicLong()).incrementAndGet(); + } + + /** The last position issued for a stream, or 0 when none has been. */ + public long current(String streamId) { + AtomicLong counter = counters.get(streamId); + return counter == null ? 0 : counter.get(); + } + + /** + * Forgets a stream. + * + *

Called when the last subscriber leaves. Without it the counter map grows with the number of + * streams that ever existed rather than the number in use — the same churn-proportional leak the + * session registry has to avoid. + */ + public void forget(String streamId) { + counters.remove(streamId); + } + + /** How many streams are being tracked. */ + public int trackedStreams() { + return counters.size(); + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/outbound/GlobalBufferBudget.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/outbound/GlobalBufferBudget.java new file mode 100644 index 00000000..593d42f9 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/outbound/GlobalBufferBudget.java @@ -0,0 +1,89 @@ +package dev.caskeleton.adapter.inbound.websocket.outbound; + +import java.util.concurrent.atomic.AtomicLong; + +/** + * A ceiling on unsent bytes across every connection on this node. + * + *

The bound a per-connection limit cannot provide. Per-connection buffering is sized for a + * plausible connection; the node's exposure is that number times the connection count, and the + * connection count is chosen by whoever connects. A megabyte each is fine at a hundred connections + * and is the whole heap at fifty thousand. + * + *

This is the difference between one slow consumer degrading its own connection and a + * coordinated set of them taking down the node — and the second is trivially arranged, because + * "read slowly" requires no tooling at all. + */ +public final class GlobalBufferBudget { + + private final long maxBufferedBytes; + private final AtomicLong buffered = new AtomicLong(); + private final AtomicLong peak = new AtomicLong(); + + /** + * A budget for one node. + * + * @param maxBufferedBytes the most unsent data this node may hold across all connections + */ + public GlobalBufferBudget(long maxBufferedBytes) { + if (maxBufferedBytes <= 0) { + throw new IllegalArgumentException("a node that may buffer nothing can write nothing"); + } + this.maxBufferedBytes = maxBufferedBytes; + } + + /** + * Reserves space for a message. + * + * @return false when the node is at its ceiling + */ + public boolean reserve(int byteCount) { + if (byteCount <= 0) { + return true; + } + // Compare-and-set rather than add-then-check. Adding first lets every concurrent writer push + // past the ceiling before any of them notices, and under backpressure there are many + // concurrent writers by definition. + while (true) { + long current = buffered.get(); + long next = current + byteCount; + if (next > maxBufferedBytes) { + return false; + } + if (buffered.compareAndSet(current, next)) { + peak.accumulateAndGet(next, Math::max); + return true; + } + } + } + + /** Releases space once bytes have been written or discarded. */ + public void release(int byteCount) { + if (byteCount <= 0) { + return; + } + // Floored at zero. A double release would otherwise drive the counter negative and hand out + // capacity the node does not have — the same defect as a double-released permit. + buffered.accumulateAndGet(byteCount, (current, released) -> Math.max(0, current - released)); + } + + /** How many bytes are currently unsent across the node. */ + public long buffered() { + return buffered.get(); + } + + /** The most that were ever unsent at once. */ + public long peak() { + return peak.get(); + } + + /** The ceiling. */ + public long maxBufferedBytes() { + return maxBufferedBytes; + } + + /** How close the node is to its ceiling, from 0 to 1. */ + public double saturation() { + return (double) buffered.get() / maxBufferedBytes; + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/outbound/OutboundDelivery.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/outbound/OutboundDelivery.java new file mode 100644 index 00000000..b35e1610 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/outbound/OutboundDelivery.java @@ -0,0 +1,38 @@ +package dev.caskeleton.adapter.inbound.websocket.outbound; + +/** + * What the platform is allowed to do with a message when the peer cannot keep up. + * + *

The decision has to be made per message, because a connection carries both kinds. A live price + * tick is worthless a second later and dropping it is correct; the acknowledgement of a payment is + * not, and dropping it silently loses the only record the client will ever see. + * + *

A platform with one policy for the whole connection has to choose which of those to get wrong. + */ +public enum OutboundDelivery { + + /** + * May be discarded under pressure. + * + *

For state that is superseded by the next message. Dropping a stale tick is not data loss; it + * is the correct behaviour, and buffering it is what turns a slow consumer into a heap problem. + */ + DROPPABLE, + + /** + * Superseded by a newer message for the same key. + * + *

Better than dropping where a key exists: the peer gets the current value rather than an old + * one, and the queue holds one entry per key instead of a history. + */ + CONFLATABLE, + + /** + * Must be delivered or the connection must fail. + * + *

For anything the client cannot reconstruct. When this cannot be sent the honest outcome is + * to close the connection — a client that reconnects and resynchronises has lost nothing, while a + * client that silently missed one is wrong and does not know it. + */ + GUARANTEED +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/outbound/OutboundEnqueueResult.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/outbound/OutboundEnqueueResult.java new file mode 100644 index 00000000..a7c339fb --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/outbound/OutboundEnqueueResult.java @@ -0,0 +1,29 @@ +package dev.caskeleton.adapter.inbound.websocket.outbound; + +/** + * What happened when a message was offered to a connection's queue. + * + *

Four answers rather than a boolean, because the caller's next move differs for each. A dropped + * droppable message is normal and should not be logged as a failure; a replaced conflated one is + * normal and should not be counted as a drop; and a refused guaranteed message means the connection + * must be closed, which nothing else in this set implies. + */ +public enum OutboundEnqueueResult { + + /** Queued and will be written. */ + ACCEPTED, + + /** Superseded an older message with the same key. */ + CONFLATED, + + /** Discarded because the queue is full and the message was droppable. */ + DROPPED, + + /** + * Refused: the queue is full and the message could not be dropped. + * + *

The connection has to close. A client that reconnects and resynchronises has lost nothing; + * one that silently missed a guaranteed message is wrong and does not know it. + */ + CONNECTION_MUST_CLOSE +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/outbound/OutboundMessage.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/outbound/OutboundMessage.java new file mode 100644 index 00000000..113f16e4 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/outbound/OutboundMessage.java @@ -0,0 +1,49 @@ +package dev.caskeleton.adapter.inbound.websocket.outbound; + +import dev.caskeleton.adapter.inbound.websocket.protocol.WebSocketMessageId; +import java.nio.charset.StandardCharsets; +import java.util.Objects; +import java.util.Optional; + +/** + * One encoded message waiting to be written. + * + *

Already encoded, on purpose. Encoding while holding the write lock would put serialization + * time inside the section that serialises every write on the connection; encoding at enqueue also + * means the queue's byte accounting is exact rather than estimated, and an estimate is what makes a + * buffer bound approximate. + * + * @param messageId what to record it as + * @param payload the encoded document + * @param delivery what may be done with it under pressure + * @param priority when it leaves relative to others + * @param conflationKey which messages supersede it, for a conflatable message + */ +public record OutboundMessage( + WebSocketMessageId messageId, + String payload, + OutboundDelivery delivery, + OutboundPriority priority, + Optional conflationKey) { + + public OutboundMessage { + Objects.requireNonNull(messageId, "messageId"); + Objects.requireNonNull(payload, "payload"); + Objects.requireNonNull(delivery, "delivery"); + Objects.requireNonNull(priority, "priority"); + Objects.requireNonNull(conflationKey, "conflationKey"); + if (delivery == OutboundDelivery.CONFLATABLE && conflationKey.isEmpty()) { + throw new IllegalArgumentException( + "a conflatable message needs a key; without one there is nothing to say which message" + + " supersedes which, and conflation degrades to dropping the older one"); + } + if (delivery != OutboundDelivery.CONFLATABLE && conflationKey.isPresent()) { + throw new IllegalArgumentException("only a conflatable message carries a conflation key"); + } + } + + /** How many bytes this message costs the connection's buffer. */ + public int byteCount() { + return payload.getBytes(StandardCharsets.UTF_8).length; + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/outbound/OutboundPriority.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/outbound/OutboundPriority.java new file mode 100644 index 00000000..190871d8 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/outbound/OutboundPriority.java @@ -0,0 +1,43 @@ +package dev.caskeleton.adapter.inbound.websocket.outbound; + +/** + * Which messages leave first when the queue is not empty. + * + *

Three levels, and the top one exists for a specific failure. Under backpressure the queue is + * full of application data, and the messages that manage the connection — a close, a credential + * expiry warning, an error explaining why something was dropped — would queue behind all of it. The + * peer then learns its connection is closing well after it closed. + */ +public enum OutboundPriority { + + /** + * Connection control: close notices, expiry warnings, error reports. + * + *

Ahead of everything, because these explain what is happening to the connection itself. A + * close notice behind ten thousand ticks arrives after the close. + */ + CONTROL(0), + + /** Answers to something the client asked for. */ + RESPONSE(1), + + /** Unsolicited events and stream data. */ + EVENT(2); + + private final int rank; + + OutboundPriority(int rank) { + this.rank = rank; + } + + /** + * Lower is sooner. + * + *

Declared rather than taken from {@code ordinal()}: inserting a level in the middle would + * renumber everything after it, and a comparator built on declaration position would silently + * reorder the queue. + */ + public int rank() { + return rank; + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/outbound/OutboundQueue.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/outbound/OutboundQueue.java new file mode 100644 index 00000000..7b4c1a62 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/outbound/OutboundQueue.java @@ -0,0 +1,189 @@ +package dev.caskeleton.adapter.inbound.websocket.outbound; + +import dev.caskeleton.adapter.inbound.websocket.budget.WebSocketConnectionBudget; +import java.util.ArrayDeque; +import java.util.Comparator; +import java.util.Deque; +import java.util.EnumMap; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +/** + * One connection's pending writes, bounded in both count and bytes. + * + *

Both bounds, because each admits what the other refuses. A count bound alone lets a thousand + * one-megabyte messages through; a byte bound alone lets a million one-byte messages through, and + * the per-entry overhead of a million queue entries is itself the problem. Neither is redundant. + * + *

Priority is applied at dequeue rather than by keeping a sorted structure, and messages within + * a priority stay in the order they were enqueued. That second part matters: reordering two events + * on the same stream is a correctness bug for anything the client applies in sequence, and a + * priority queue with no tiebreak reorders equal elements freely. + * + *

Not thread-safe on its own. It is owned by the serialized writer, which is the only thing + * permitted to touch a connection's outbound path — a queue with its own lock would suggest + * otherwise and invite a second writer. + */ +public final class OutboundQueue { + + private final Map> lanes = + new EnumMap<>(OutboundPriority.class); + private final Map conflatable = new HashMap<>(); + private final GlobalBufferBudget globalBudget; + private final int maxMessages; + private final long maxBytes; + private long bufferedBytes; + private long droppedMessages; + private boolean losslessOverflowed; + + /** + * A queue for one connection. + * + * @param budget the connection's own bounds + * @param maxMessages how many messages may wait + * @param globalBudget the node-wide ceiling this connection draws from + */ + public OutboundQueue( + WebSocketConnectionBudget budget, int maxMessages, GlobalBufferBudget globalBudget) { + Objects.requireNonNull(budget, "budget"); + this.globalBudget = Objects.requireNonNull(globalBudget, "globalBudget"); + if (maxMessages <= 0) { + throw new IllegalArgumentException("a queue that holds nothing writes nothing"); + } + this.maxMessages = maxMessages; + this.maxBytes = budget.maxBufferedOutboundBytes(); + for (OutboundPriority priority : OutboundPriority.values()) { + lanes.put(priority, new ArrayDeque<>()); + } + } + + /** + * Offers a message to the queue. + * + * @param message the encoded message + * @return what happened, which the caller must act on for a guaranteed message + */ + public OutboundEnqueueResult offer(OutboundMessage message) { + Objects.requireNonNull(message, "message"); + + if (message.delivery() == OutboundDelivery.CONFLATABLE) { + String key = message.conflationKey().orElseThrow(); + OutboundMessage superseded = conflatable.get(key); + if (superseded != null) { + // Replaced in place rather than appended, so the queue holds one entry per key instead of + // a history the peer will never catch up with. + replace(superseded, message); + return OutboundEnqueueResult.CONFLATED; + } + } + + int bytes = message.byteCount(); + boolean roomLocally = size() < maxMessages && bufferedBytes + bytes <= maxBytes; + if (!roomLocally || !globalBudget.reserve(bytes)) { + if (roomLocally) { + // The node ceiling refused it, not this connection's. Nothing was reserved, so nothing is + // released. + return refuse(message); + } + return refuse(message); + } + + lanes.get(message.priority()).addLast(message); + bufferedBytes += bytes; + message.conflationKey().ifPresent(key -> conflatable.put(key, message)); + return OutboundEnqueueResult.ACCEPTED; + } + + /** The next message to write, in priority then arrival order. */ + public Optional poll() { + return lanes.entrySet().stream() + .sorted(Comparator.comparingInt(entry -> entry.getKey().rank())) + .map(Map.Entry::getValue) + .filter(lane -> !lane.isEmpty()) + .findFirst() + .map( + lane -> { + OutboundMessage message = lane.pollFirst(); + bufferedBytes -= message.byteCount(); + globalBudget.release(message.byteCount()); + message.conflationKey().ifPresent(key -> conflatable.remove(key, message)); + return message; + }); + } + + /** How many messages are waiting. */ + public int size() { + return lanes.values().stream().mapToInt(Deque::size).sum(); + } + + /** How many bytes are waiting. */ + public long bufferedBytes() { + return bufferedBytes; + } + + /** How many messages this connection has discarded. */ + public long droppedMessages() { + return droppedMessages; + } + + /** + * What the queue holds right now. + * + *

One object rather than four getters, so a caller cannot read the count and the byte total + * from two different moments and report a pair that never existed. This queue is written by the + * source and drained by the writer, so any two separate reads straddle a mutation. + */ + public OutboundQueueSnapshot snapshot() { + return new OutboundQueueSnapshot(size(), bufferedBytes, droppedMessages, losslessOverflowed); + } + + /** + * Releases everything, for a connection that is closing. + * + *

The global reservation has to be given back explicitly. Dropping the queue on the floor + * would leak node-wide capacity for every connection that ever closed with a backlog, which is + * every connection that was ever slow. + */ + public void discardAll() { + lanes + .values() + .forEach( + lane -> { + lane.forEach(message -> globalBudget.release(message.byteCount())); + lane.clear(); + }); + conflatable.clear(); + bufferedBytes = 0; + } + + private OutboundEnqueueResult refuse(OutboundMessage message) { + if (message.delivery() == OutboundDelivery.GUARANTEED) { + // Recorded, not just returned. A dropped best-effort message and a refused guaranteed one + // are different incidents: the first is the policy working, the second is a connection that + // must be closed and reconciled — and only the second should reach anybody's attention. + losslessOverflowed = true; + return OutboundEnqueueResult.CONNECTION_MUST_CLOSE; + } + droppedMessages++; + return OutboundEnqueueResult.DROPPED; + } + + private void replace(OutboundMessage superseded, OutboundMessage replacement) { + Deque lane = lanes.get(superseded.priority()); + lane.remove(superseded); + globalBudget.release(superseded.byteCount()); + bufferedBytes -= superseded.byteCount(); + // Reserved for the replacement before it is queued. Assuming the sizes match would let a + // growing conflated value drift past both bounds one byte at a time. + if (globalBudget.reserve(replacement.byteCount())) { + lanes.get(replacement.priority()).addLast(replacement); + bufferedBytes += replacement.byteCount(); + conflatable.put(replacement.conflationKey().orElseThrow(), replacement); + } else { + conflatable.remove(superseded.conflationKey().orElseThrow()); + droppedMessages++; + } + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/outbound/OutboundQueueSnapshot.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/outbound/OutboundQueueSnapshot.java new file mode 100644 index 00000000..8f253694 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/outbound/OutboundQueueSnapshot.java @@ -0,0 +1,33 @@ +package dev.caskeleton.adapter.inbound.websocket.outbound; + +/** + * What one connection's outbound queue holds, as an observation. + * + *

Counts and flags only. No payload, no message type, no correlation id and no actor — this is + * the shape that reaches a metric, an admin endpoint and an incident screenshot, and every one of + * those is a place a dynamic identifier becomes an unbounded metric tag or a disclosure. + * + *

{@code droppedCount} and {@code overflowed} are separate on purpose. A queue that dropped + * something under a DROP policy is working as configured; one that overflowed a LOSSLESS stream is + * a connection that is about to be closed and reconciled. Collapsing them would make the second + * invisible in the metric that ought to page somebody. + * + * @param messageCount how many messages are waiting + * @param byteCount how many bytes they occupy + * @param droppedCount how many were dropped under a DROP policy since the connection opened + * @param overflowed whether a lossless stream exceeded the bound + */ +public record OutboundQueueSnapshot( + int messageCount, long byteCount, long droppedCount, boolean overflowed) { + + public OutboundQueueSnapshot { + if (messageCount < 0 || byteCount < 0 || droppedCount < 0) { + throw new IllegalArgumentException("a queue observation cannot be negative"); + } + } + + /** Whether this connection is in a state that needs closing rather than draining. */ + public boolean requiresReconciliation() { + return overflowed; + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/outbound/SerializedOutboundWriter.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/outbound/SerializedOutboundWriter.java new file mode 100644 index 00000000..0c0d5986 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/outbound/SerializedOutboundWriter.java @@ -0,0 +1,188 @@ +package dev.caskeleton.adapter.inbound.websocket.outbound; + +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketConnectionId; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.locks.ReentrantLock; + +/** + * The one thing permitted to write to a connection, ever. + * + *

Both the servlet and the reactive WebSocket APIs are explicit that concurrent sends on one + * session are undefined, and what "undefined" means in practice is frame interleaving: two messages + * written at once produce a stream where one message's continuation frames sit inside another's. + * The peer's parser then rejects the whole connection, and the failure looks like corruption from a + * network no packet capture will show a problem with. + * + *

Serialising is therefore not a performance choice; it is a correctness requirement, and the + * reason a handler is given no way to write. Every path to the socket goes through one of these. + * + *

{@code tryLock} rather than {@code lock}: a caller that finds the writer busy hands the work + * to the queue and returns instead of blocking. On the reactive stack blocking here would park an + * event-loop thread, and on the servlet stack it would hold a request thread waiting on a slow + * peer's socket — which is the slow-consumer problem again, moved into the thread pool. + */ +public final class SerializedOutboundWriter { + + /** Where bytes actually go. Implemented once per runtime. */ + @FunctionalInterface + public interface FrameSink { + + /** + * Writes one encoded message. + * + * @param payload the encoded document + * @throws Exception when the transport refuses it + */ + void write(String payload) throws Exception; + } + + private final WebSocketConnectionId connectionId; + private final OutboundQueue queue; + private final FrameSink sink; + + /** + * Guards the socket. Held for as long as a write takes, which on a peer that has stopped reading + * is until its send buffer drains or the write times out. + */ + private final ReentrantLock writeLock = new ReentrantLock(); + + /** + * Guards the queue, and deliberately not the same lock as the socket. + * + *

One lock for both was the first version and it was wrong in the exact way this class exists + * to prevent: a stalled peer holds the write lock inside the sink, so every producer calling + * {@code offer} blocks behind it. The slow consumer becomes a slow producer, the queue never + * fills, and the bound that was supposed to shed load never fires. Two locks means enqueueing is + * always quick and only the socket waits. + */ + private final ReentrantLock queueLock = new ReentrantLock(); + + private final AtomicBoolean closed = new AtomicBoolean(); + private long written; + private long failed; + + /** + * A writer for one connection. + * + * @param connectionId which connection + * @param queue its pending writes + * @param sink where bytes go + */ + public SerializedOutboundWriter( + WebSocketConnectionId connectionId, OutboundQueue queue, FrameSink sink) { + this.connectionId = Objects.requireNonNull(connectionId, "connectionId"); + this.queue = Objects.requireNonNull(queue, "queue"); + this.sink = Objects.requireNonNull(sink, "sink"); + } + + /** + * Offers a message and writes what it can. + * + * @return what happened to this message + */ + public OutboundEnqueueResult offer(OutboundMessage message) { + if (closed.get()) { + return OutboundEnqueueResult.CONNECTION_MUST_CLOSE; + } + OutboundEnqueueResult result; + // The queue is not thread-safe on its own — it is owned by this writer, which is the only + // thing allowed near the connection's outbound path. Guarded by its own short-held lock so a + // stalled socket cannot block a producer from queueing. + queueLock.lock(); + try { + result = queue.offer(message); + } finally { + queueLock.unlock(); + } + drain(); + return result; + } + + /** + * Writes whatever is queued, if nobody else is already writing. + * + *

Returns immediately when another thread holds the writer. That thread will pick up what was + * just queued before it finishes, so nothing is stranded — and the caller is not parked on a slow + * peer's socket. + */ + public void drain() { + if (closed.get() || !writeLock.tryLock()) { + return; + } + try { + while (true) { + Optional next; + // Polled under the queue lock and written outside it, so a slow write never holds the + // queue. + queueLock.lock(); + try { + next = queue.poll(); + } finally { + queueLock.unlock(); + } + if (next.isEmpty()) { + return; + } + OutboundMessage message = next.get(); + try { + sink.write(message.payload()); + written++; + } catch (Exception refused) { + failed++; + // Stop at the first failure. Continuing would write the rest of the queue into a socket + // that has already refused one message, and on a half-closed connection those writes + // succeed silently into nothing. + closed.set(true); + queueLock.lock(); + try { + queue.discardAll(); + } finally { + queueLock.unlock(); + } + return; + } + } + } finally { + writeLock.unlock(); + } + } + + /** Closes the writer and releases everything the queue was holding. */ + public void close() { + if (!closed.compareAndSet(false, true)) { + return; + } + // The queue lock, not the write lock: a stalled sink still holds the write lock, and taking it + // here would make close() wait for the very peer it is giving up on. + queueLock.lock(); + try { + // Released explicitly: the node-wide buffer budget would otherwise keep counting bytes for a + // connection that no longer exists. + queue.discardAll(); + } finally { + queueLock.unlock(); + } + } + + /** Whether this writer has stopped. */ + public boolean closed() { + return closed.get(); + } + + /** How many messages reached the transport. */ + public long written() { + return written; + } + + /** How many the transport refused. */ + public long failed() { + return failed; + } + + /** Which connection this writes to. */ + public WebSocketConnectionId connectionId() { + return connectionId; + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/protocol/WebSocketCatalogFingerprint.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/protocol/WebSocketCatalogFingerprint.java new file mode 100644 index 00000000..e5fbda74 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/protocol/WebSocketCatalogFingerprint.java @@ -0,0 +1,64 @@ +package dev.caskeleton.adapter.inbound.websocket.protocol; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.List; +import java.util.Objects; + +/** + * A digest of the published catalog, for detecting drift between two deployments. + * + *

Two nodes serving the same subprotocol version and different catalogs is the failure this + * exists to make visible. It happens during every rolling deploy and is normally harmless — until a + * client connects to the new node, subscribes to a type the old node does not know, reconnects to + * the old node, and receives an error for a type the API says exists. + * + *

The fingerprint is over the published surface only: the type name, its family, its direction + * and its major version. Not the minor, because a minor bump is additive and both nodes can serve + * it — making the fingerprint change on an additive release would report drift on every deploy and + * teach everyone to ignore it. + * + * @param value a hex digest of the catalog's published surface + */ +public record WebSocketCatalogFingerprint(String value) { + + public WebSocketCatalogFingerprint { + Objects.requireNonNull(value, "value"); + if (value.length() != 64) { + throw new IllegalArgumentException("a catalog fingerprint is a 64-character hex digest"); + } + } + + /** + * The fingerprint of a set of descriptors. + * + * @param descriptors every published message type + */ + public static WebSocketCatalogFingerprint of(List descriptors) { + Objects.requireNonNull(descriptors, "descriptors"); + // Sorted, so two nodes that registered the same types in different orders agree. Without this + // the fingerprint reports drift caused by bean ordering. + List lines = + descriptors.stream() + .map( + descriptor -> + descriptor.type().value() + + '\u001f' + + descriptor.family() + + '\u001f' + + descriptor.direction() + + '\u001f' + + descriptor.schemaVersion().major()) + .sorted() + .toList(); + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + lines.forEach(line -> digest.update(line.getBytes(StandardCharsets.UTF_8))); + return new WebSocketCatalogFingerprint(HexFormat.of().formatHex(digest.digest())); + } catch (NoSuchAlgorithmException impossible) { + throw new IllegalStateException("SHA-256 is required of every JVM", impossible); + } + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/protocol/WebSocketCodecProfile.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/protocol/WebSocketCodecProfile.java new file mode 100644 index 00000000..844d524d --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/protocol/WebSocketCodecProfile.java @@ -0,0 +1,17 @@ +package dev.caskeleton.adapter.inbound.websocket.protocol; + +/** + * How a message is encoded on the wire. + * + *

One member in Stable, and that is the decision rather than an oversight. A second codec + * doubles the decode surface, and the decode path is where a hostile frame is cheapest to exploit — + * every additional format is another parser reachable before authentication has been re-checked. + * + *

The enum exists so that adding one later is a visible change with a reviewed name, instead of + * a content-type string appearing in a handler. + */ +public enum WebSocketCodecProfile { + + /** UTF-8 JSON text frames. */ + JSON +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/protocol/WebSocketCorrelationId.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/protocol/WebSocketCorrelationId.java new file mode 100644 index 00000000..693a6bce --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/protocol/WebSocketCorrelationId.java @@ -0,0 +1,36 @@ +package dev.caskeleton.adapter.inbound.websocket.protocol; + +import java.util.Objects; +import java.util.regex.Pattern; + +/** + * Which earlier message this one answers. + * + *

A separate type from {@link WebSocketMessageId} even though the grammars match. On a + * connection that multiplexes many in-flight requests, mixing up "my identity" and "what I am + * answering" routes a response to the wrong waiter — and the symptom is one caller receiving + * another's data, which is a disclosure rather than a bug. + * + * @param value the identifier of the message being answered + */ +public record WebSocketCorrelationId(String value) { + + private static final Pattern GRAMMAR = Pattern.compile("[A-Za-z0-9_-]{1,64}"); + + public WebSocketCorrelationId { + Objects.requireNonNull(value, "value"); + if (!GRAMMAR.matcher(value).matches()) { + throw new IllegalArgumentException("a correlation id must match [A-Za-z0-9_-]{1,64}"); + } + } + + /** The correlation that answers a given message. */ + public static WebSocketCorrelationId answering(WebSocketMessageId messageId) { + return new WebSocketCorrelationId(messageId.value()); + } + + @Override + public String toString() { + return value; + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/protocol/WebSocketEnvelope.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/protocol/WebSocketEnvelope.java new file mode 100644 index 00000000..95de3d7c --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/protocol/WebSocketEnvelope.java @@ -0,0 +1,150 @@ +package dev.caskeleton.adapter.inbound.websocket.protocol; + +import java.time.Instant; +import java.util.Objects; +import java.util.Optional; + +/** + * One message on the wire, with the fields its family is allowed to carry and no others. + * + *

The payload is a {@code String} — the encoded document — rather than a {@code Map} or an + * {@code Object}. That is the design's rule and it is worth stating why, because a map is the + * obvious and wrong choice: it accepts any shape, so nothing is validated until some handler + * reaches for a key that is not there; it makes an entity or a document trivially serializable + * straight onto the wire, which publishes the storage schema; and it is where unbounded nesting + * arrives, because a map has no depth of its own. + * + *

Keeping the payload opaque here forces decoding to happen in one place, against a declared + * type from a closed catalog, with the budget applied. The envelope's job is routing, and routing + * needs the type name and the correlation, not the content. + * + *

The constructor refuses every field a family may not carry. Those are not tidiness rules: a + * {@code sequence} on an unordered family invites gap detection on something nobody ordered, and an + * {@code expiresAt} on a response is a deadline the sender cannot act on. + * + * @param messageId this message's identity + * @param family what kind of message it is + * @param type the published shape name + * @param correlationId which message this answers, for families that answer + * @param streamId which ordered stream it belongs to, for orderable families + * @param sequence its position in that stream + * @param expiresAt when a command stops being worth executing + * @param payload the encoded document, opaque at this layer + */ +public record WebSocketEnvelope( + WebSocketMessageId messageId, + WebSocketMessageFamily family, + WebSocketMessageType type, + Optional correlationId, + Optional streamId, + Optional sequence, + Optional expiresAt, + Optional payload) { + + public WebSocketEnvelope { + Objects.requireNonNull(messageId, "messageId"); + Objects.requireNonNull(family, "family"); + Objects.requireNonNull(type, "type"); + Objects.requireNonNull(correlationId, "correlationId"); + Objects.requireNonNull(streamId, "streamId"); + Objects.requireNonNull(sequence, "sequence"); + Objects.requireNonNull(expiresAt, "expiresAt"); + Objects.requireNonNull(payload, "payload"); + + if (family.requiresCorrelation() && correlationId.isEmpty()) { + throw new IllegalArgumentException( + family + " answers an earlier message and must name it, or the waiter never resolves"); + } + if (!family.orderable() && (streamId.isPresent() || sequence.isPresent())) { + throw new IllegalArgumentException( + family + + " is not an ordered family; a sequence here invites gap detection on something" + + " nobody ordered, and the missing-message alarms are about nothing"); + } + if (family.orderable() && streamId.isPresent() != sequence.isPresent()) { + // Half of an ordering is not an ordering: a stream without positions cannot be gap-checked, + // and a position without a stream cannot be compared to anything. + throw new IllegalArgumentException( + "a stream id and a sequence are meaningless apart; " + family + " has only one"); + } + if (!family.expirable() && expiresAt.isPresent()) { + throw new IllegalArgumentException( + family + " has no deadline the sender could act on; only a command expires"); + } + if (!family.carriesPayload() && payload.isPresent()) { + throw new IllegalArgumentException(family + " carries no payload"); + } + sequence.ifPresent( + value -> { + if (value < 0) { + throw new IllegalArgumentException("a sequence position cannot be negative"); + } + }); + streamId.ifPresent( + value -> { + if (value.isBlank() || value.length() > 64) { + throw new IllegalArgumentException( + "a stream id must be non-blank and at most 64 characters"); + } + }); + } + + /** A command, optionally with a deadline. */ + public static WebSocketEnvelope command( + WebSocketMessageId messageId, WebSocketMessageType type, String payload, Instant expiresAt) { + return new WebSocketEnvelope( + messageId, + WebSocketMessageFamily.COMMAND, + type, + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.ofNullable(expiresAt), + Optional.of(payload)); + } + + /** The answer to a command. */ + public static WebSocketEnvelope response( + WebSocketMessageId messageId, + WebSocketMessageType type, + WebSocketCorrelationId correlationId, + String payload) { + return new WebSocketEnvelope( + messageId, + WebSocketMessageFamily.RESPONSE, + type, + Optional.of(correlationId), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.of(payload)); + } + + /** An event, optionally positioned in a stream. */ + public static WebSocketEnvelope event( + WebSocketMessageId messageId, + WebSocketMessageType type, + String streamId, + Long sequence, + String payload) { + return new WebSocketEnvelope( + messageId, + WebSocketMessageFamily.EVENT, + type, + Optional.empty(), + Optional.ofNullable(streamId), + Optional.ofNullable(sequence), + Optional.empty(), + Optional.of(payload)); + } + + /** + * Whether a command has outlived its deadline. + * + *

Checked before the handler runs, never after. A client that gave up waiting has usually + * retried, and executing both is exactly the duplicate the deadline exists to prevent. + */ + public boolean expiredAt(Instant now) { + return expiresAt.map(deadline -> !now.isBefore(deadline)).orElse(false); + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/protocol/WebSocketMessageCatalog.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/protocol/WebSocketMessageCatalog.java new file mode 100644 index 00000000..52af68ea --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/protocol/WebSocketMessageCatalog.java @@ -0,0 +1,100 @@ +package dev.caskeleton.adapter.inbound.websocket.protocol; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +/** + * Every message type this deployment publishes, fixed at startup. + * + *

Closed, and that is the security property rather than a style preference. A decoder that + * resolves an arbitrary type name from the wire can be asked to resolve one nobody intended; a + * decoder that looks a name up in this catalog and refuses a miss cannot. Unknown types are + * rejected at the protocol boundary and never reach an application handler. + * + *

Duplicates fail at startup for the same reason two endpoints on one path do: which descriptor + * wins would be decided by registration order, and the loser is silently unreachable. + */ +public final class WebSocketMessageCatalog { + + private final Map byType; + private final WebSocketCatalogFingerprint fingerprint; + + private WebSocketMessageCatalog( + Map byType, + WebSocketCatalogFingerprint fingerprint) { + this.byType = byType; + this.fingerprint = fingerprint; + } + + /** + * A catalog over the published descriptors. + * + * @param descriptors every message type this deployment publishes + * @throws IllegalArgumentException when a type is declared twice + */ + public static WebSocketMessageCatalog of(List descriptors) { + Objects.requireNonNull(descriptors, "descriptors"); + Map byType = new LinkedHashMap<>(); + for (WebSocketMessageDescriptor descriptor : descriptors) { + if (byType.putIfAbsent(descriptor.type(), descriptor) != null) { + throw new IllegalArgumentException( + "message type " + + descriptor.type() + + " is declared twice; which descriptor wins would be decided by registration" + + " order and the other would be silently unreachable"); + } + } + return new WebSocketMessageCatalog( + Map.copyOf(byType), WebSocketCatalogFingerprint.of(List.copyOf(byType.values()))); + } + + /** The descriptor for a type, if it is published. */ + public Optional find(WebSocketMessageType type) { + return Optional.ofNullable(byType.get(type)); + } + + /** + * Whether a client may send this type. + * + *

The check that runs before decoding, on every inbound message. An unpublished type and a + * server-only type get the same answer: a client learns nothing about what exists that it may not + * use. + */ + public boolean acceptsFromClient(WebSocketMessageType type) { + return find(type).map(descriptor -> descriptor.direction().acceptsFromClient()).orElse(false); + } + + /** Whether the server may send this type. */ + public boolean acceptsFromServer(WebSocketMessageType type) { + return find(type).map(descriptor -> descriptor.direction().acceptsFromServer()).orElse(false); + } + + /** + * The descriptor for an inbound type, or empty when the message must be refused. + * + * @param type the wire type name + * @param family the family the envelope claims + */ + public Optional admitFromClient( + WebSocketMessageType type, WebSocketMessageFamily family) { + return find(type) + .filter(descriptor -> descriptor.direction().acceptsFromClient()) + // The claimed family has to match the published one. A client that could label a command + // as an event would route it past whatever the platform does per family — the expiry check + // and the correlation requirement both key on it. + .filter(descriptor -> descriptor.family() == family); + } + + /** Every published descriptor. */ + public List all() { + return List.copyOf(byType.values()); + } + + /** The digest of this catalog's published surface. */ + public WebSocketCatalogFingerprint fingerprint() { + return fingerprint; + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/protocol/WebSocketMessageDescriptor.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/protocol/WebSocketMessageDescriptor.java new file mode 100644 index 00000000..34a39e0e --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/protocol/WebSocketMessageDescriptor.java @@ -0,0 +1,40 @@ +package dev.caskeleton.adapter.inbound.websocket.protocol; + +import java.util.Objects; + +/** + * One entry in the published message catalog. + * + *

Binds a wire type name to the family it belongs to and the direction it may travel. Direction + * is not decoration: a client that can send an {@code EVENT} can inject something the server will + * relay to other subscribers as though the server had produced it, and a client that can send a + * {@code RESPONSE} can answer a command it never received. + * + * @param type the published shape name + * @param family what kind of message it is + * @param direction who may send it + * @param schemaVersion the payload schema this type currently carries + */ +public record WebSocketMessageDescriptor( + WebSocketMessageType type, + WebSocketMessageFamily family, + WebSocketMessageDirection direction, + WebSocketSchemaVersion schemaVersion) { + + public WebSocketMessageDescriptor { + Objects.requireNonNull(type, "type"); + Objects.requireNonNull(family, "family"); + Objects.requireNonNull(direction, "direction"); + Objects.requireNonNull(schemaVersion, "schemaVersion"); + if (type.version() != schemaVersion.major()) { + // The wire name carries a version and so does the schema. Letting them disagree means the + // name a client matches on says one thing and the document it receives is another. + throw new IllegalArgumentException( + "the type name declares v" + + type.version() + + " and the schema declares v" + + schemaVersion.major() + + "; a client matching on the name would receive a document of a different shape"); + } + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/protocol/WebSocketMessageDirection.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/protocol/WebSocketMessageDirection.java new file mode 100644 index 00000000..e12060f0 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/protocol/WebSocketMessageDirection.java @@ -0,0 +1,36 @@ +package dev.caskeleton.adapter.inbound.websocket.protocol; + +/** + * Who is allowed to send a message type. + * + *

A WebSocket is symmetric at the transport layer — either end may send any frame at any time — + * and that symmetry is not something an application wants. Without a declared direction a client + * can send an event the server relays to other subscribers as if the server had produced it, or a + * response to a command that was never issued. + */ +public enum WebSocketMessageDirection { + + /** Only the client may send it. */ + CLIENT_TO_SERVER, + + /** Only the server may send it. */ + SERVER_TO_CLIENT, + + /** + * Either end may send it. + * + *

Rare and deliberate: a heartbeat is the honest case. Anything else marked bidirectional is + * usually two message types that have not been separated yet. + */ + BIDIRECTIONAL; + + /** Whether a client may send this type. */ + public boolean acceptsFromClient() { + return this == CLIENT_TO_SERVER || this == BIDIRECTIONAL; + } + + /** Whether the server may send this type. */ + public boolean acceptsFromServer() { + return this == SERVER_TO_CLIENT || this == BIDIRECTIONAL; + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/protocol/WebSocketMessageFamily.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/protocol/WebSocketMessageFamily.java new file mode 100644 index 00000000..91389cd2 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/protocol/WebSocketMessageFamily.java @@ -0,0 +1,64 @@ +package dev.caskeleton.adapter.inbound.websocket.protocol; + +/** + * What kind of thing a message is, and therefore which envelope fields it may carry. + * + *

A closed family set rather than a free-form type string, because the optional fields on an + * envelope are only meaningful per family. A {@code correlationId} on an event means nothing; a + * {@code sequence} on a command means nothing; an {@code expiresAt} on a response means nothing. + * Without families every field is optional on every message and the receiver has to guess which + * ones it can trust. + */ +public enum WebSocketMessageFamily { + + /** Client asks the server to do something. May be correlated, may expire. */ + COMMAND, + + /** The server's answer to one command. Always correlated. */ + RESPONSE, + + /** The server reports something that happened. May be ordered within a stream. */ + EVENT, + + /** The client subscribes to or unsubscribes from a stream. Always correlated. */ + SUBSCRIPTION, + + /** Liveness. Carries nothing but its own identity. */ + HEARTBEAT, + + /** A typed failure, either as an answer to a command or unsolicited. */ + ERROR; + + /** Whether this family answers an earlier message and must name it. */ + public boolean requiresCorrelation() { + return this == RESPONSE || this == SUBSCRIPTION; + } + + /** + * Whether this family may carry a sequence and a stream. + * + *

Only ordered families. A sequence on a command invites a receiver to reorder or gap-detect + * something the sender never ordered, and the resulting "missing message" alarms are about + * nothing. + */ + public boolean orderable() { + return this == EVENT; + } + + /** + * Whether this family may declare an expiry. + * + *

Commands only. An expired command must be refused before the handler runs — a client that + * gave up waiting has usually retried, and executing both is the duplicate the expiry exists to + * prevent. Nothing else has a deadline that means anything: an event that arrives late is still + * news. + */ + public boolean expirable() { + return this == COMMAND; + } + + /** Whether this family carries an application payload at all. */ + public boolean carriesPayload() { + return this != HEARTBEAT; + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/protocol/WebSocketMessageId.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/protocol/WebSocketMessageId.java new file mode 100644 index 00000000..0389e2e1 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/protocol/WebSocketMessageId.java @@ -0,0 +1,28 @@ +package dev.caskeleton.adapter.inbound.websocket.protocol; + +import java.util.Objects; +import java.util.regex.Pattern; + +/** + * One message's identity. + * + * @param value the identifier + */ +public record WebSocketMessageId(String value) { + + private static final Pattern GRAMMAR = Pattern.compile("[A-Za-z0-9_-]{1,64}"); + + public WebSocketMessageId { + Objects.requireNonNull(value, "value"); + if (!GRAMMAR.matcher(value).matches()) { + throw new IllegalArgumentException( + "a message id must match [A-Za-z0-9_-]{1,64}; it is echoed back to the sender and" + + " written to logs, so an unbounded or control-bearing value is both"); + } + } + + @Override + public String toString() { + return value; + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/protocol/WebSocketMessageType.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/protocol/WebSocketMessageType.java new file mode 100644 index 00000000..96d655cb --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/protocol/WebSocketMessageType.java @@ -0,0 +1,62 @@ +package dev.caskeleton.adapter.inbound.websocket.protocol; + +import java.util.Objects; +import java.util.regex.Pattern; + +/** + * The published name of a message's shape. + * + *

Explicitly not a Java class name. A fully qualified class name on the wire is a published + * contract nobody meant to publish: it tells a client the server's package layout, it breaks every + * client the day a class is renamed or moved, and on the inbound side it is the shape that makes + * deserialization gadget attacks possible — a receiver that resolves a type from a wire string can + * be asked to resolve one the author never considered. + * + *

A dotted, versioned, lowercase name instead. It survives refactoring, it says nothing about + * the server's internals, and it is matched against a closed catalog rather than a class loader. + * + * @param value the published name + */ +public record WebSocketMessageType(String value) { + + private static final Pattern GRAMMAR = + Pattern.compile("[a-z][a-z0-9]*(\\.[a-z][a-z0-9]*)*\\.v[0-9]+"); + + /** Shapes that mean a caller put a Java type name on the wire. */ + private static final Pattern LOOKS_LIKE_A_CLASS_NAME = + Pattern.compile(".*(^|\\.)(java|javax|jakarta|com|org|net|io|dev)\\..*[A-Z].*"); + + public WebSocketMessageType { + Objects.requireNonNull(value, "value"); + if (LOOKS_LIKE_A_CLASS_NAME.matcher(value).matches()) { + throw new IllegalArgumentException( + "'" + + value + + "' looks like a Java class name. A class name on the wire publishes the server's" + + " package layout, breaks every client on a rename, and turns the receiver's type" + + " resolution into an attack surface"); + } + if (!GRAMMAR.matcher(value).matches()) { + throw new IllegalArgumentException( + "a message type must be lowercase, dotted and version-suffixed, like" + + " 'order.placed.v1'; was '" + + value + + "'"); + } + } + + /** The major version this type declares. */ + public int version() { + return Integer.parseInt(value.substring(value.lastIndexOf(".v") + 2)); + } + + /** The name without its version suffix. */ + public String unversionedName() { + return value.substring(0, value.lastIndexOf(".v")); + } + + @Override + public String toString() { + return value; + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/protocol/WebSocketProtocolProfile.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/protocol/WebSocketProtocolProfile.java new file mode 100644 index 00000000..8c1197ae --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/protocol/WebSocketProtocolProfile.java @@ -0,0 +1,101 @@ +package dev.caskeleton.adapter.inbound.websocket.protocol; + +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketSubprotocolName; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Objects; + +/** + * Which subprotocols an endpoint speaks, and what it does when a client offers none. + * + *

The fallback is the interesting field. RFC 6455 lets a client omit {@code + * Sec-WebSocket-Protocol} entirely, and the server may accept the connection anyway — at which + * point both ends have agreed on nothing and each assumes its own current format. That works until + * the format changes, and then it fails on a connection that has been open long enough for nobody + * to associate the failure with a deploy. + * + *

So a production endpoint must name at least one subprotocol and must refuse a client that + * offers none. A local compatibility profile may accept one, because a developer with a browser + * console and no client library is a real case and the blast radius is one machine. + * + * @param supported the subprotocols this endpoint accepts, in server preference order + * @param codec how messages are encoded + * @param allowUnnegotiatedFallback whether a client offering no subprotocol is accepted + */ +public record WebSocketProtocolProfile( + // A List, not a Set. Preference order is the whole point of this field, and an immutable Set + // does not keep it: Set.copyOf reorders by hash, so the profile silently negotiated whichever + // token happened to come first. + List supported, + WebSocketCodecProfile codec, + boolean allowUnnegotiatedFallback) { + + public WebSocketProtocolProfile { + Objects.requireNonNull(supported, "supported"); + Objects.requireNonNull(codec, "codec"); + supported = List.copyOf(new LinkedHashSet<>(supported)); + if (supported.isEmpty() && !allowUnnegotiatedFallback) { + throw new IllegalArgumentException( + "an endpoint that names no subprotocol and refuses the fallback accepts nothing at all"); + } + } + + /** The Stable production profile: one versioned subprotocol, JSON, no fallback. */ + public static WebSocketProtocolProfile stable() { + return new WebSocketProtocolProfile( + List.of(WebSocketSubprotocolName.stable()), WebSocketCodecProfile.JSON, false); + } + + /** + * A local profile that accepts a browser console with no client library. + * + *

Named for where it belongs. A profile that allowed the fallback silently would be + * indistinguishable from the Stable one in review. + */ + public static WebSocketProtocolProfile localCompatibility() { + return new WebSocketProtocolProfile( + List.of(WebSocketSubprotocolName.stable()), WebSocketCodecProfile.JSON, true); + } + + /** + * The subprotocol to answer a handshake with, or empty when the handshake must be refused. + * + * @param offered what the client sent, in its preference order + */ + public java.util.Optional negotiate( + List offered) { + Objects.requireNonNull(offered, "offered"); + if (offered.isEmpty()) { + // The server may only answer with a token the client offered, so an unnegotiated fallback + // is an accepted connection with no agreed protocol rather than a defaulted one. + return java.util.Optional.empty(); + } + // Server preference, not client preference. The client's order is a request; which format this + // deployment would rather speak is the server's decision, and letting the client choose means + // an old client pins every server to an old format. + for (WebSocketSubprotocolName candidate : supported) { + if (offered.contains(candidate)) { + return java.util.Optional.of(candidate); + } + } + return java.util.Optional.empty(); + } + + /** + * Whether a handshake offering these subprotocols may be accepted. + * + * @param offered what the client sent + */ + public boolean acceptsHandshake(List offered) { + Objects.requireNonNull(offered, "offered"); + if (offered.isEmpty()) { + return allowUnnegotiatedFallback; + } + return negotiate(offered).isPresent(); + } + + /** Whether this profile is safe to serve in production. */ + public boolean productionReady() { + return !supported.isEmpty() && !allowUnnegotiatedFallback; + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/protocol/WebSocketSchemaVersion.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/protocol/WebSocketSchemaVersion.java new file mode 100644 index 00000000..e26518c2 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/protocol/WebSocketSchemaVersion.java @@ -0,0 +1,52 @@ +package dev.caskeleton.adapter.inbound.websocket.protocol; + +import java.util.Objects; + +/** + * The version of a message payload's shape. + * + *

Major and minor, with the difference doing real work. A minor bump is additive — a new + * optional field — and an old client can keep reading. A major bump is not, and a connection that + * negotiated the old major must never be handed the new shape. + * + *

The reason this is separate from the version in the type name: the name's version is what a + * client matches on and cannot change without breaking every client, while the minor here moves + * whenever a field is added. Conflating them would make every additive change a breaking one. + * + * @param major the incompatible version, matching the type name's suffix + * @param minor the additive revision within that major + */ +public record WebSocketSchemaVersion(int major, int minor) { + + public WebSocketSchemaVersion { + if (major < 1) { + throw new IllegalArgumentException("a schema major version starts at 1, was " + major); + } + if (minor < 0) { + throw new IllegalArgumentException("a schema minor version cannot be negative"); + } + } + + /** The first version of a shape. */ + public static WebSocketSchemaVersion v(int major) { + return new WebSocketSchemaVersion(major, 0); + } + + /** + * Whether a peer that understands {@code other} can read a document of this version. + * + *

Same major, and this minor no newer than theirs is trivially fine; this minor *newer* than + * theirs is also fine, because a minor bump only adds optional fields and a strict decoder is + * configured to reject unknown ones — which is why the catalog, not the decoder, has to answer + * this question. + */ + public boolean readableBy(WebSocketSchemaVersion other) { + Objects.requireNonNull(other, "other"); + return major == other.major(); + } + + @Override + public String toString() { + return "v" + major + "." + minor; + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/release/WebSocketNginxProxyProfile.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/release/WebSocketNginxProxyProfile.java new file mode 100644 index 00000000..f74df682 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/release/WebSocketNginxProxyProfile.java @@ -0,0 +1,91 @@ +package dev.caskeleton.adapter.inbound.websocket.release; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** + * What a proxy in front of this platform has to do, stated so it can be checked. + * + *

Written down because a WebSocket behind a proxy fails in ways that look like application bugs. + * The three below have each cost somebody a day. + * + *

The read timeout must exceed the heartbeat. A proxy that closes an idle + * upstream connection after sixty seconds will cut a connection whose heartbeat is every ninety, + * and the server sees a client that vanished while the client sees a server that did. Neither log + * says "proxy". + * + *

{@code Upgrade} and {@code Connection} must be passed explicitly. They are + * hop-by-hop headers, so a proxy drops them by default and the upstream sees an ordinary GET — + * which answers 200 with a body, and the client reports a protocol error against a server that did + * nothing wrong. + * + *

Incoming forwarded headers must be replaced, not appended to. A client can + * send its own {@code X-Forwarded-For}, and a proxy that appends leaves the application trusting + * the first entry — which the client chose. That is a client-controlled client IP, reaching the + * rate limiter and the audit log. + * + * @param proxyReadTimeout how long the proxy holds an idle upstream connection + * @param heartbeatInterval how often the platform writes a keepalive + * @param forwardsUpgradeHeaders whether Upgrade and Connection reach the upstream + * @param sanitizesForwardedHeaders whether client-supplied forwarded headers are replaced + * @param masksCredentialsInAccessLog whether tickets and tokens are kept out of the access log + */ +public record WebSocketNginxProxyProfile( + Duration proxyReadTimeout, + Duration heartbeatInterval, + boolean forwardsUpgradeHeaders, + boolean sanitizesForwardedHeaders, + boolean masksCredentialsInAccessLog) { + + public WebSocketNginxProxyProfile { + Objects.requireNonNull(proxyReadTimeout, "proxyReadTimeout"); + Objects.requireNonNull(heartbeatInterval, "heartbeatInterval"); + if (proxyReadTimeout.isNegative() || heartbeatInterval.isNegative()) { + throw new IllegalArgumentException("a timeout is not negative"); + } + } + + /** A profile that satisfies every requirement. */ + public static WebSocketNginxProxyProfile compliant(Duration heartbeatInterval) { + Objects.requireNonNull(heartbeatInterval, "heartbeatInterval"); + return new WebSocketNginxProxyProfile( + heartbeatInterval.multipliedBy(3), heartbeatInterval, true, true, true); + } + + /** What is wrong with this proxy configuration. */ + public List faults() { + List faults = new ArrayList<>(); + if (proxyReadTimeout.compareTo(heartbeatInterval) <= 0) { + faults.add( + "the proxy read timeout (" + + proxyReadTimeout + + ") is not longer than the heartbeat (" + + heartbeatInterval + + "); the proxy will cut connections the platform believes are healthy, and neither" + + " log will say proxy"); + } + if (!forwardsUpgradeHeaders) { + faults.add( + "Upgrade and Connection are hop-by-hop and are dropped unless forwarded explicitly; the" + + " upstream then sees an ordinary GET and answers 200"); + } + if (!sanitizesForwardedHeaders) { + faults.add( + "client-supplied forwarded headers are appended rather than replaced, so the client" + + " chooses the address the rate limiter and the audit log believe"); + } + if (!masksCredentialsInAccessLog) { + faults.add( + "handshake tickets appear in the access log, which is retained longer and read more" + + " widely than anything else that ever holds a credential"); + } + return List.copyOf(faults); + } + + /** Whether the proxy may front this platform. */ + public boolean compliant() { + return faults().isEmpty(); + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/release/WebSocketRollingRestartScenario.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/release/WebSocketRollingRestartScenario.java new file mode 100644 index 00000000..3f6a305d --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/release/WebSocketRollingRestartScenario.java @@ -0,0 +1,113 @@ +package dev.caskeleton.adapter.inbound.websocket.release; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** + * What a rolling restart has to demonstrate, in order. + * + *

The order is the content. Every step protects the one after it, and skipping any of them + * produces a restart that looks graceful and is not. + * + *

    + *
  • Readiness goes down first. If it stays up while the node drains, the balancer + * keeps sending connections and the drain never finishes. + *
  • New handshakes are refused. A connection accepted during a drain is one that will be cut + * seconds later. + *
  • The queue drains within the deadline. What is still queued at the deadline is lost, so the + * deadline has to be longer than a full queue takes to write. + *
  • Close is 1012 — service restart — not 1001 or 1006. A client can tell "come back" from + * "something broke" only by the code, and 1006 means it never saw one at all. + *
  • Clients reconnect with jitter. Without it every client of the restarted node reconnects in + * the same instant and the replacement is met by the entire population at once. + *
  • No duplicate command is produced. A reconnect that resends an in-flight command needs the + * idempotency ledger to recognise it; if it does not, the restart charged somebody twice. + *
+ * + * @param drainDeadline how long the node waits before forcing what remains + * @param expectedCloseCode the code clients must observe + * @param reconnectJitter the spread a compliant client applies + */ +public record WebSocketRollingRestartScenario( + Duration drainDeadline, int expectedCloseCode, Duration reconnectJitter) { + + /** RFC 6455's service-restart close code. */ + public static final int SERVICE_RESTART = 1012; + + public WebSocketRollingRestartScenario { + Objects.requireNonNull(drainDeadline, "drainDeadline"); + Objects.requireNonNull(reconnectJitter, "reconnectJitter"); + if (drainDeadline.isNegative() || drainDeadline.isZero()) { + throw new IllegalArgumentException( + "a drain with no deadline never completes; there is always one client that does not" + + " reconnect"); + } + if (reconnectJitter.isNegative() || reconnectJitter.isZero()) { + throw new IllegalArgumentException( + "without jitter every client of the restarted node reconnects in the same instant"); + } + if (expectedCloseCode != SERVICE_RESTART) { + throw new IllegalArgumentException( + "a rolling restart closes 1012; 1001 says the server is going away for good and 1006" + + " says the client never saw a close frame at all"); + } + } + + /** The conventional scenario. */ + public static WebSocketRollingRestartScenario conventional() { + return new WebSocketRollingRestartScenario( + Duration.ofSeconds(30), SERVICE_RESTART, Duration.ofSeconds(5)); + } + + /** + * What the observed run failed to show. + * + * @param readinessDroppedFirst whether readiness went down before draining began + * @param newHandshakesRefused whether the draining node refused new connections + * @param queueDrainedWithinDeadline whether the outbound queue emptied in time + * @param observedCloseCode the code clients actually saw + * @param clientsJittered whether reconnects were spread + * @param duplicateCommands how many commands were executed twice + */ + public List blockers( + boolean readinessDroppedFirst, + boolean newHandshakesRefused, + boolean queueDrainedWithinDeadline, + int observedCloseCode, + boolean clientsJittered, + int duplicateCommands) { + List blockers = new ArrayList<>(); + if (!readinessDroppedFirst) { + blockers.add( + "readiness stayed up during the drain, so the balancer kept sending connections and the" + + " drain cannot finish"); + } + if (!newHandshakesRefused) { + blockers.add("the draining node accepted a handshake it was about to cut"); + } + if (!queueDrainedWithinDeadline) { + blockers.add( + "the outbound queue did not empty within " + drainDeadline + "; what remains is lost"); + } + if (observedCloseCode != expectedCloseCode) { + blockers.add( + "clients observed close code " + + observedCloseCode + + " rather than " + + expectedCloseCode + + "; they cannot tell 'come back' from 'something broke'"); + } + if (!clientsJittered) { + blockers.add("reconnects were not spread, so the replacement node met the whole population"); + } + if (duplicateCommands > 0) { + blockers.add( + duplicateCommands + + " command(s) executed twice across the reconnect; the idempotency ledger did not" + + " recognise the resend"); + } + return List.copyOf(blockers); + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/release/WebSocketStableReleaseGate.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/release/WebSocketStableReleaseGate.java new file mode 100644 index 00000000..ffe126cd --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/release/WebSocketStableReleaseGate.java @@ -0,0 +1,106 @@ +package dev.caskeleton.adapter.inbound.websocket.release; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +/** + * What the Stable platform must show before it is promoted. + * + *

Distinct from the Advanced gate beside it, and stricter in the one dimension that matters: + * Advanced capabilities are off unless a deployment names them, so a broken one affects whoever + * enabled it. Stable is what every deployment gets, so its evidence has to cover every runtime it + * claims to run on rather than the one the author happened to test. + * + *

{@code advancedAbsentFromArtifact} is the condition that keeps the two apart. If an Advanced + * type reached the Stable artifact, then Stable does not build without Advanced and the separation + * is a naming convention — which is exactly what WS-ARCH-6 refuses at compile time and this refuses + * at release time. Both, because the first catches a source edge and this catches a packaging one. + */ +public record WebSocketStableReleaseGate( + Set requiredRuntimes, + Set requiredSuites, + boolean advancedAbsentFromArtifact, + boolean supportMatrixPublished) { + + /** + * The runtimes Stable claims to support. + * + *

Every one is a real container, because upgrade negotiation, close-frame timing and idle + * handling are container code — a mock dispatcher certifies none of it, and the three containers + * disagree about all three. + */ + public static final Set RUNTIMES = Set.of("tomcat", "jetty", "reactor-netty", "nginx"); + + /** + * The suites whose absence has, at some point, hidden a real defect. + * + *

Not a general list. Commit-response-loss and slow-consumer are named because each covers a + * failure that unit tests reported as passing: a control that existed and was reached by nothing. + */ + public static final Set SUITES = + Set.of( + "websocket:test", + "websocketJettyTest", + "websocketNginxTest", + "commit-response-loss", + "slow-consumer"); + + public WebSocketStableReleaseGate { + requiredRuntimes = Set.copyOf(Objects.requireNonNull(requiredRuntimes, "requiredRuntimes")); + requiredSuites = Set.copyOf(Objects.requireNonNull(requiredSuites, "requiredSuites")); + if (requiredRuntimes.isEmpty() || requiredSuites.isEmpty()) { + throw new IllegalArgumentException( + "a gate requiring no runtime or no suite passes everything, which is worse than no gate" + + " because it reads as one"); + } + } + + /** The standard gate: every claimed runtime, every named suite. */ + public static WebSocketStableReleaseGate standard() { + return new WebSocketStableReleaseGate(RUNTIMES, SUITES, false, false); + } + + /** + * Why the platform may not be promoted yet. + * + * @param verifiedRuntimes the runtimes evidence actually covers + * @param passedSuites the suites that actually passed + */ + public List blockers(Set verifiedRuntimes, Set passedSuites) { + Objects.requireNonNull(verifiedRuntimes, "verifiedRuntimes"); + Objects.requireNonNull(passedSuites, "passedSuites"); + List blockers = new ArrayList<>(); + List missingRuntimes = + requiredRuntimes.stream().filter(r -> !verifiedRuntimes.contains(r)).sorted().toList(); + if (!missingRuntimes.isEmpty()) { + blockers.add( + "no evidence on: " + + missingRuntimes + + "; upgrade negotiation and close-frame timing are container code and the" + + " containers disagree"); + } + List missingSuites = + requiredSuites.stream().filter(s -> !passedSuites.contains(s)).sorted().toList(); + if (!missingSuites.isEmpty()) { + blockers.add("suites not passed: " + missingSuites); + } + if (!advancedAbsentFromArtifact) { + blockers.add( + "an Advanced type is present in the Stable artifact, so Stable does not build without" + + " Advanced and the separation is a naming convention"); + } + if (!supportMatrixPublished) { + blockers.add( + "the support matrix is unpublished; a platform that does not say what it does not" + + " support is read as supporting it"); + } + return List.copyOf(blockers); + } + + /** Whether promotion may proceed. */ + public boolean promotable(Set verifiedRuntimes, Set passedSuites) { + return blockers(verifiedRuntimes, passedSuites).isEmpty(); + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/security/WebSocketAuthenticationProfile.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/security/WebSocketAuthenticationProfile.java new file mode 100644 index 00000000..56aec31c --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/security/WebSocketAuthenticationProfile.java @@ -0,0 +1,57 @@ +package dev.caskeleton.adapter.inbound.websocket.security; + +/** + * How a handshake proves who is connecting. + * + *

The constraint that shapes all of this: a browser's {@code WebSocket} constructor cannot set + * headers. There is no way to send {@code Authorization} from a browser, which is why so many + * WebSocket deployments end up putting a token in the query string — where it lands in access logs, + * in {@code Referer} headers, and in browser history. + * + *

So the profiles here are the three that actually work, and the query-string token is not one + * of them. + */ +public enum WebSocketAuthenticationProfile { + + /** + * A one-time ticket, minted over an authenticated HTTP call and spent at the handshake. + * + *

The right answer for a browser. The ticket is short-lived, single-use and bound to the + * actor, so its appearance in a log is worth nothing by the time anyone reads it — unlike the + * bearer token it stands in for. + */ + ONE_TIME_TICKET, + + /** + * A cookie the browser attaches on its own. + * + *

Works, and brings CSRF with it. A WebSocket handshake is a cross-origin-capable GET that the + * same-origin policy does not protect, so a cookie-authenticated endpoint must check {@code + * Origin} — that check is the entire defence, and there is no preflight to fall back on. + */ + SESSION_COOKIE, + + /** + * An {@code Authorization} header, for a non-browser client. + * + *

Available to server-to-server callers and native apps, which can set headers. Not available + * to a browser, so an endpoint that requires it has decided it is not for browsers. + */ + BEARER_HEADER; + + /** + * Whether this profile is subject to CSRF. + * + *

True exactly when the browser attaches the credential without the page asking. A ticket is + * fetched by the page and a header is set by the client, so neither travels on a cross-origin + * handshake the attacker initiated. + */ + public boolean ambientlyAttached() { + return this == SESSION_COOKIE; + } + + /** Whether a browser can use this profile at all. */ + public boolean browserCapable() { + return this != BEARER_HEADER; + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/security/WebSocketConnectionTicket.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/security/WebSocketConnectionTicket.java new file mode 100644 index 00000000..884bd320 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/security/WebSocketConnectionTicket.java @@ -0,0 +1,77 @@ +package dev.caskeleton.adapter.inbound.websocket.security; + +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketActorReference; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketEndpointName; +import java.time.Duration; +import java.time.Instant; +import java.util.Objects; +import java.util.regex.Pattern; + +/** + * A single-use credential minted over authenticated HTTP and spent at the handshake. + * + *

It exists because a browser cannot set headers on a {@code WebSocket} constructor. The usual + * workaround is a bearer token in the query string, which then appears in the access log, in the + * {@code Referer} of anything the page loads next, and in browser history — a long-lived credential + * written to three places nobody audits. + * + *

A ticket goes in the same query string and is worth nothing by the time anyone reads it: it + * expires in seconds, it works once, and it is bound to one endpoint and one actor. All three + * bounds are needed. Without single use, a ticket in a log is replayable. Without an endpoint + * binding, a ticket minted for a low-privilege feed opens a privileged one. Without an actor + * binding, it is a bearer token again. + * + * @param value the opaque ticket + * @param actor who it was minted for + * @param endpoint which endpoint it opens + * @param issuedAt when it was minted + * @param expiresAt when it stops working + */ +public record WebSocketConnectionTicket( + String value, + WebSocketActorReference actor, + WebSocketEndpointName endpoint, + Instant issuedAt, + Instant expiresAt) { + + /** How long a ticket may live at most. */ + public static final Duration MAX_LIFETIME = Duration.ofSeconds(30); + + private static final Pattern GRAMMAR = Pattern.compile("[A-Za-z0-9_-]{32,128}"); + + public WebSocketConnectionTicket { + Objects.requireNonNull(value, "value"); + Objects.requireNonNull(actor, "actor"); + Objects.requireNonNull(endpoint, "endpoint"); + Objects.requireNonNull(issuedAt, "issuedAt"); + Objects.requireNonNull(expiresAt, "expiresAt"); + if (!GRAMMAR.matcher(value).matches()) { + // At least 32 characters of URL-safe entropy. A ticket short enough to guess is a ticket an + // attacker mints by trying, and it travels in a query string where rate limiting is coarse. + throw new IllegalArgumentException( + "a connection ticket must be 32-128 URL-safe characters; shorter is guessable and it" + + " travels somewhere that is logged"); + } + if (!expiresAt.isAfter(issuedAt)) { + throw new IllegalArgumentException("a ticket that expires when minted opens nothing"); + } + if (Duration.between(issuedAt, expiresAt).compareTo(MAX_LIFETIME) > 0) { + // The lifetime is what makes a logged ticket harmless. Extend it and the query-string + // exposure the ticket was introduced to fix comes back. + throw new IllegalArgumentException( + "a ticket may live at most " + + MAX_LIFETIME.toSeconds() + + "s; a longer one is a bearer token in a query string again"); + } + } + + /** Whether the ticket is still within its window. */ + public boolean validAt(Instant now) { + return !now.isBefore(issuedAt) && now.isBefore(expiresAt); + } + + /** Whether this ticket opens the given endpoint for the given actor. */ + public boolean opens(WebSocketEndpointName target, WebSocketActorReference caller) { + return endpoint.equals(target) && actor.equals(caller); + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/security/WebSocketOriginPolicy.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/security/WebSocketOriginPolicy.java new file mode 100644 index 00000000..c3a8bac4 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/security/WebSocketOriginPolicy.java @@ -0,0 +1,116 @@ +package dev.caskeleton.adapter.inbound.websocket.security; + +import java.util.Locale; +import java.util.Objects; +import java.util.Set; + +/** + * Which origins may open a connection. + * + *

The single most load-bearing check in a cookie-authenticated WebSocket, and the one most often + * left out — because it looks like CORS and is not. The same-origin policy does not apply to a + * WebSocket handshake: a page on any origin can open one to any host, the browser will attach the + * cookies, and there is no preflight and no {@code Access-Control-Allow-Origin} negotiation to + * refuse it. The server checking {@code Origin} itself is the whole defence. + * + *

A missing {@code Origin} header is therefore not the same as an empty one. Browsers always + * send it on a WebSocket handshake; a non-browser client usually sends none. Refusing the absent + * case would lock out every server-to-server caller, and accepting it blindly would let an + * attacker's script omit the header — except a script cannot, which is exactly why the two cases + * are decided separately here. + */ +public final class WebSocketOriginPolicy { + + private final Set allowedOrigins; + private final boolean allowMissingOrigin; + + private WebSocketOriginPolicy(Set allowedOrigins, boolean allowMissingOrigin) { + this.allowedOrigins = allowedOrigins; + this.allowMissingOrigin = allowMissingOrigin; + } + + /** + * A policy for browser clients on known origins. + * + *

A missing origin is refused: every browser sends one, so its absence means the caller is not + * the browser this endpoint is for. + */ + public static WebSocketOriginPolicy browsersOnly(Set origins) { + return new WebSocketOriginPolicy(normalize(origins), false); + } + + /** + * A policy that also admits non-browser clients. + * + *

Only sound where the credential is not ambiently attached. A cookie-authenticated endpoint + * that allowed a missing origin would accept exactly the request a CSRF attack cannot make — and + * would gain nothing, because the attacker's page cannot omit the header anyway. + */ + public static WebSocketOriginPolicy browsersAndServices(Set origins) { + return new WebSocketOriginPolicy(normalize(origins), true); + } + + /** + * Whether a handshake carrying this {@code Origin} may proceed. + * + * @param origin the header value, or null when the client sent none + */ + public boolean permits(String origin) { + if (origin == null || origin.isBlank()) { + return allowMissingOrigin; + } + // Exact match, never a suffix test. `endsWith(".example.com")` admits both + // "evil-example.com" and "example.com.attacker.net". + return allowedOrigins.contains(origin.toLowerCase(Locale.ROOT)); + } + + /** + * Whether this policy is safe for a given authentication profile. + * + *

Read by the startup validator. A cookie profile with an empty allowlist is an endpoint with + * no CSRF defence at all, and it will work perfectly in every test that connects from the same + * origin. + */ + public boolean safeFor(WebSocketAuthenticationProfile profile) { + if (!profile.ambientlyAttached()) { + return true; + } + return !allowedOrigins.isEmpty() && !allowMissingOrigin; + } + + /** The permitted origins. */ + public Set allowedOrigins() { + return allowedOrigins; + } + + private static Set normalize(Set origins) { + Objects.requireNonNull(origins, "origins"); + Set normalized = new java.util.TreeSet<>(); + for (String origin : origins) { + Objects.requireNonNull(origin, "origin"); + String lower = origin.toLowerCase(Locale.ROOT); + if (lower.isBlank()) { + throw new IllegalArgumentException("a blank origin is not an origin"); + } + if (lower.contains("*")) { + // A wildcard reads as "any subdomain" and matches nothing, because origins are compared + // exactly. The entry looks like a policy and is a no-op. + throw new IllegalArgumentException( + "origin '" + + origin + + "' contains a wildcard; origins are compared exactly, so this" + + " matches nothing while reading as though it matches a family"); + } + if (!lower.startsWith("http://") && !lower.startsWith("https://")) { + throw new IllegalArgumentException( + "origin '" + origin + "' must be scheme://host[:port]; a bare host never matches"); + } + if (lower.chars().filter(character -> character == '/').count() != 2) { + throw new IllegalArgumentException( + "origin '" + origin + "' must carry no path or trailing slash"); + } + normalized.add(lower); + } + return Set.copyOf(normalized); + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/security/WebSocketTicketStore.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/security/WebSocketTicketStore.java new file mode 100644 index 00000000..68737e66 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/security/WebSocketTicketStore.java @@ -0,0 +1,32 @@ +package dev.caskeleton.adapter.inbound.websocket.security; + +import java.time.Instant; +import java.util.Optional; + +/** + * Where tickets are held between minting and spending. + * + *

{@link #redeem} both reads and consumes, atomically, and that is the entire contract. A store + * offering a separate lookup would be used as read-then-delete, and two handshakes arriving with + * the same ticket would both find it — which is single-use in name only. Reconnect storms deliver + * exactly those concurrent duplicates. + */ +public interface WebSocketTicketStore { + + /** Records a freshly minted ticket. */ + void issue(WebSocketConnectionTicket ticket); + + /** + * Consumes a ticket, if it exists and has not expired. + * + *

Atomic: a second call with the same value returns empty even if it arrives in the same + * millisecond. + * + * @param value the opaque ticket presented at the handshake + * @param now the current instant + */ + Optional redeem(String value, Instant now); + + /** Drops expired tickets. */ + int purgeExpired(Instant now); +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/servlet/PlatformWebSocketHandler.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/servlet/PlatformWebSocketHandler.java new file mode 100644 index 00000000..801f5ab2 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/servlet/PlatformWebSocketHandler.java @@ -0,0 +1,205 @@ +package dev.caskeleton.adapter.inbound.websocket.servlet; + +import dev.caskeleton.adapter.inbound.websocket.budget.WebSocketConnectionBudget; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketConnectionId; +import dev.caskeleton.adapter.inbound.websocket.error.WebSocketCloseCode; +import dev.caskeleton.adapter.inbound.websocket.error.WebSocketClosePolicy; +import dev.caskeleton.adapter.inbound.websocket.error.WebSocketFailureCategory; +import dev.caskeleton.adapter.inbound.websocket.inbound.FragmentAssembler; +import dev.caskeleton.adapter.inbound.websocket.inbound.FragmentAssemblyException; +import dev.caskeleton.adapter.inbound.websocket.outbound.GlobalBufferBudget; +import dev.caskeleton.adapter.inbound.websocket.outbound.OutboundQueue; +import dev.caskeleton.adapter.inbound.websocket.outbound.SerializedOutboundWriter; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.BiFunction; +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketSession; +import org.springframework.web.socket.handler.TextWebSocketHandler; + +/** + * The servlet runtime's entry point: container callbacks in, platform values out. + * + *

Everything here is translation. The container hands it a session and a message; it hands the + * platform a string and gets back a decision. The platform types that do the deciding — the + * assembler, the queue, the writer — have no idea a servlet exists, which is what lets the same + * decisions serve the reactive runtime and be tested without a container. + * + *

Per-connection state lives in maps keyed by session id rather than in the session's own + * attributes. Attributes are a {@code Map} the container also writes to, and + * anything stored there is reachable by every other handler in the application — including a + * fragment assembler holding a partially received message. + */ +public class PlatformWebSocketHandler extends TextWebSocketHandler + implements org.springframework.context.SmartLifecycle { + + private final WebSocketConnectionBudget budget; + private final GlobalBufferBudget globalBudget; + private final BiFunction> application; + private final Map assemblers = new ConcurrentHashMap<>(); + private final Map writers = new ConcurrentHashMap<>(); + private final Map sessions = new ConcurrentHashMap<>(); + private volatile boolean running; + + /** + * A handler over one budget. + * + * @param budget what a connection may consume + * @param globalBudget the node-wide outbound ceiling + * @param application handles one decoded message and optionally answers + */ + public PlatformWebSocketHandler( + WebSocketConnectionBudget budget, + GlobalBufferBudget globalBudget, + BiFunction> application) { + this.budget = Objects.requireNonNull(budget, "budget"); + this.globalBudget = Objects.requireNonNull(globalBudget, "globalBudget"); + this.application = Objects.requireNonNull(application, "application"); + } + + @Override + public void afterConnectionEstablished(WebSocketSession session) { + sessions.put(session.getId(), session); + assemblers.put(session.getId(), new FragmentAssembler(budget)); + writers.put( + session.getId(), + new SerializedOutboundWriter( + connectionId(session), + new OutboundQueue(budget, 256, globalBudget), + new ServletFrameSink(session))); + } + + @Override + protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { + FragmentAssembler assembler = assemblers.get(session.getId()); + if (assembler == null) { + return; + } + Optional whole; + try { + whole = assembler.accept(message.getPayload(), message.isLast()); + } catch (FragmentAssemblyException tooLarge) { + closeFor(session, tooLarge.category(), tooLarge.getMessage()); + return; + } + if (whole.isEmpty()) { + return; + } + application + .apply(connectionId(session), whole.get()) + .ifPresent(reply -> writeReply(session, reply)); + } + + @Override + public void afterConnectionClosed(WebSocketSession session, CloseStatus status) { + // Both removed, and the writer closed rather than dropped: the node-wide budget would + // otherwise keep counting bytes for a connection that no longer exists, and the leak is + // proportional to churn. + sessions.remove(session.getId()); + assemblers.remove(session.getId()); + SerializedOutboundWriter writer = writers.remove(session.getId()); + if (writer != null) { + writer.close(); + } + } + + @Override + public void handleTransportError(WebSocketSession session, Throwable exception) { + afterConnectionClosed(session, CloseStatus.SERVER_ERROR); + } + + /** How many connections this handler currently holds. */ + public int openConnections() { + return writers.size(); + } + + @Override + public void start() { + running = true; + } + + /** + * Closes every live connection before the container stops. + * + *

Necessary because a container's graceful shutdown does not do it. Both Tomcat and Jetty wait + * for in-flight *requests*, and an established WebSocket is not a request — so a graceful + * shutdown completes with the connections still open, and they die when the socket is torn down. + * The client sees a 1006: "closed abnormally, no reason given", indistinguishable from a network + * failure, which sends it into its most aggressive reconnect path at exactly the moment the fleet + * is being restarted. + * + *

Closing them here with 1001 tells every client the truth — this node is going away — and a + * client that is told that reconnects to a healthy node instead of hammering this one. + */ + @Override + public void stop() { + running = false; + sessions.forEach( + (id, session) -> { + try { + if (session.isOpen()) { + session.close(CloseStatus.GOING_AWAY); + } + } catch (Exception alreadyGone) { + // The peer beat us to it. Nothing left to tell it. + } + }); + writers.values().forEach(SerializedOutboundWriter::close); + sessions.clear(); + writers.clear(); + assemblers.clear(); + } + + @Override + public boolean isRunning() { + return running; + } + + @Override + public int getPhase() { + // Stopped before the web server, which is the ordering that matters: after the server stops + // there is no connection left to send a close frame on. + return Integer.MIN_VALUE + 1; + } + + private void writeReply(WebSocketSession session, String reply) { + SerializedOutboundWriter writer = writers.get(session.getId()); + if (writer == null) { + return; + } + writer.offer( + new dev.caskeleton.adapter.inbound.websocket.outbound.OutboundMessage( + new dev.caskeleton.adapter.inbound.websocket.protocol.WebSocketMessageId( + Integer.toHexString(reply.hashCode()) + "-r"), + reply, + dev.caskeleton.adapter.inbound.websocket.outbound.OutboundDelivery.GUARANTEED, + dev.caskeleton.adapter.inbound.websocket.outbound.OutboundPriority.RESPONSE, + Optional.empty())); + } + + private void closeFor( + WebSocketSession session, WebSocketFailureCategory category, String reason) { + WebSocketCloseCode code = + WebSocketClosePolicy.closeCodeFor(category).orElse(WebSocketCloseCode.POLICY_VIOLATION); + try { + // The reason is truncated to 123 bytes because RFC 6455 caps the close frame's reason at + // that, and a container handed a longer one throws while closing — turning a clean refusal + // into an abnormal 1006 the client cannot interpret. + session.close( + new CloseStatus(code.code(), reason.length() > 100 ? reason.substring(0, 100) : reason)); + } catch (Exception alreadyGone) { + // The peer closed first. Nothing left to tell it. + } + } + + private static WebSocketConnectionId connectionId(WebSocketSession session) { + String id = session.getId(); + // Padded: container session ids are short, and the platform's identifier grammar requires at + // least eight characters so an identifier is never guessable by counting. + return new WebSocketConnectionId( + id.length() >= 8 ? id : "sess" + "0".repeat(8 - id.length()) + id); + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/servlet/ServletFrameSink.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/servlet/ServletFrameSink.java new file mode 100644 index 00000000..f2a28aef --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/servlet/ServletFrameSink.java @@ -0,0 +1,45 @@ +package dev.caskeleton.adapter.inbound.websocket.servlet; + +import dev.caskeleton.adapter.inbound.websocket.outbound.SerializedOutboundWriter; +import java.io.IOException; +import java.util.Objects; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketSession; + +/** + * The servlet runtime's write path, and the only place that names a session. + * + *

One class, so the session is reachable from exactly one file. Everything above this — the + * queue, the writer, the handler — is written against values, which is what lets the same logic + * serve the reactive runtime and be tested without a container. + * + *

It does not synchronise. That is deliberate and it is not an omission: the writer above it + * already holds the connection's only write lock, and a second lock here would be a lock nobody + * needs that suggests writes from elsewhere are safe. Spring's {@code ConcurrentWebSocketSession + * Decorator} exists for callers that have no such discipline; this platform's discipline is that + * nothing else can reach the session at all. + */ +public final class ServletFrameSink implements SerializedOutboundWriter.FrameSink { + + private final WebSocketSession session; + + /** + * A sink over one session. + * + * @param session the container's session + */ + public ServletFrameSink(WebSocketSession session) { + this.session = Objects.requireNonNull(session, "session"); + } + + @Override + public void write(String payload) throws IOException { + if (!session.isOpen()) { + // Checked rather than attempted. A write to a closed servlet session throws an IOException + // whose message names the container's internals, and it would reach a log as though the + // platform had done something wrong. + throw new IOException("the session is closed"); + } + session.sendMessage(new TextMessage(payload)); + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/session/WebSocketSessionRegistry.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/session/WebSocketSessionRegistry.java new file mode 100644 index 00000000..e0658652 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/session/WebSocketSessionRegistry.java @@ -0,0 +1,163 @@ +package dev.caskeleton.adapter.inbound.websocket.session; + +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketActorReference; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketConnectionContext; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketConnectionId; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketConnectionState; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketEndpointName; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * The connections this node holds, and only this node. + * + *

Node-local by design, and the name says so because the alternative is the mistake. A registry + * that looked cluster-wide would be asked to deliver a message to a connection on another node, and + * it cannot — the socket is a file descriptor on one machine. Pretending otherwise produces a + * "send" that silently does nothing, which is far worse than an API that never offered it. + * + *

Cross-node delivery is a different problem with a different answer: publish to a bus and let + * each node deliver to its own connections. This registry is the local half of that, and it is + * complete for what it claims. + * + *

The per-actor index exists because the operations that matter are per-actor: disconnect + * everything this user has open, count what they are holding, refuse a new connection when they + * already hold too many. Scanning every connection for those is fine at a hundred and not at a + * hundred thousand. + */ +public final class WebSocketSessionRegistry { + + private final Map byId = + new ConcurrentHashMap<>(); + private final Map> byActor = + new ConcurrentHashMap<>(); + private final Map countByEndpoint = + new ConcurrentHashMap<>(); + private final int maxConnectionsPerActor; + + /** + * A registry with a per-actor cap. + * + * @param maxConnectionsPerActor how many connections one actor may hold on this node + */ + public WebSocketSessionRegistry(int maxConnectionsPerActor) { + if (maxConnectionsPerActor <= 0) { + throw new IllegalArgumentException("a registry that admits nothing holds nothing"); + } + this.maxConnectionsPerActor = maxConnectionsPerActor; + } + + /** + * Registers a newly opened connection. + * + * @return false when this actor already holds its maximum on this node + */ + public boolean register(WebSocketConnectionContext context) { + Objects.requireNonNull(context, "context"); + Map held = + byActor.computeIfAbsent(context.actor(), actor -> new ConcurrentHashMap<>()); + synchronized (held) { + // Synchronized on the per-actor map rather than the whole registry: the check and the insert + // have to be one step or two concurrent handshakes both see room, and a per-actor lock keeps + // one busy actor from serialising every other actor's connects. + if (held.size() >= maxConnectionsPerActor) { + return false; + } + held.put(context.connectionId(), Boolean.TRUE); + } + byId.put(context.connectionId(), context); + countByEndpoint + .computeIfAbsent(context.endpoint(), endpoint -> new AtomicInteger()) + .incrementAndGet(); + return true; + } + + /** Replaces a connection's context, for a state transition. */ + public void update(WebSocketConnectionContext context) { + Objects.requireNonNull(context, "context"); + byId.computeIfPresent(context.connectionId(), (id, existing) -> context); + } + + /** Removes a connection and everything indexed for it. */ + public Optional deregister(WebSocketConnectionId connectionId) { + WebSocketConnectionContext removed = byId.remove(connectionId); + if (removed == null) { + return Optional.empty(); + } + Map held = byActor.get(removed.actor()); + if (held != null) { + synchronized (held) { + held.remove(connectionId); + if (held.isEmpty()) { + // Removed when empty, or the actor index grows without bound across churn — a leak + // proportional to how many distinct users ever connected rather than to how many are + // connected now. + byActor.remove(removed.actor(), held); + } + } + } + countByEndpoint.computeIfPresent( + removed.endpoint(), (endpoint, count) -> count.decrementAndGet() <= 0 ? null : count); + return Optional.of(removed); + } + + /** The connection, if this node holds it. */ + public Optional find(WebSocketConnectionId connectionId) { + return Optional.ofNullable(byId.get(connectionId)); + } + + /** Every connection this actor holds on this node. */ + public List connectionsOf(WebSocketActorReference actor) { + Map held = byActor.get(actor); + if (held == null) { + return List.of(); + } + return held.keySet().stream().map(byId::get).filter(Objects::nonNull).toList(); + } + + /** How many connections an endpoint holds on this node. */ + public int countFor(WebSocketEndpointName endpoint) { + AtomicInteger count = countByEndpoint.get(endpoint); + return count == null ? 0 : Math.max(0, count.get()); + } + + /** How many connections this node holds in total. */ + public int size() { + return byId.size(); + } + + /** + * Moves every connection to DRAINING and returns them. + * + *

The first half of a graceful shutdown: stop accepting new inbound work while in-flight + * writes finish. Returned so the caller can close them in its own order and on its own schedule — + * a registry that closed them itself would decide the drain's pacing, which belongs to whoever + * knows how long the deploy is willing to wait. + */ + public List drainAll() { + return byId.values().stream() + .filter(context -> context.state() == WebSocketConnectionState.OPEN) + .map( + context -> { + WebSocketConnectionContext draining = + context.transitionTo(WebSocketConnectionState.DRAINING); + byId.put(draining.connectionId(), draining); + return draining; + }) + .toList(); + } + + /** The connections whose credential has expired past its grace, or which are too old. */ + public List expiredAt(Instant now, java.time.Duration maximumAge) { + return byId.values().stream() + .filter( + context -> + context.credentialExpiry().mustCloseAt(now) || context.olderThan(maximumAge, now)) + .toList(); + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/AuthenticatedHandshakeInterceptor.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/stomp/AuthenticatedHandshakeInterceptor.java similarity index 95% rename from src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/AuthenticatedHandshakeInterceptor.java rename to src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/stomp/AuthenticatedHandshakeInterceptor.java index 33a73448..ddab5858 100644 --- a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/AuthenticatedHandshakeInterceptor.java +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/stomp/AuthenticatedHandshakeInterceptor.java @@ -1,4 +1,4 @@ -package dev.caskeleton.adapter.inbound.websocket; +package dev.caskeleton.adapter.inbound.websocket.stomp; import java.security.Principal; import java.util.Map; diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/LiveEventProjector.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/stomp/LiveEventProjector.java similarity index 90% rename from src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/LiveEventProjector.java rename to src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/stomp/LiveEventProjector.java index 8815016e..258b6c15 100644 --- a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/LiveEventProjector.java +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/stomp/LiveEventProjector.java @@ -1,4 +1,4 @@ -package dev.caskeleton.adapter.inbound.websocket; +package dev.caskeleton.adapter.inbound.websocket.stomp; import java.util.Map; diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/LiveEventStompBroadcaster.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/stomp/LiveEventStompBroadcaster.java similarity index 98% rename from src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/LiveEventStompBroadcaster.java rename to src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/stomp/LiveEventStompBroadcaster.java index 6353a6a0..4aa9db2b 100644 --- a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/LiveEventStompBroadcaster.java +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/stomp/LiveEventStompBroadcaster.java @@ -1,4 +1,4 @@ -package dev.caskeleton.adapter.inbound.websocket; +package dev.caskeleton.adapter.inbound.websocket.stomp; import dev.caskeleton.domain.stereotype.DomainEvent; import java.time.Instant; diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/SafeStompSubProtocolErrorHandler.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/stomp/SafeStompSubProtocolErrorHandler.java similarity index 95% rename from src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/SafeStompSubProtocolErrorHandler.java rename to src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/stomp/SafeStompSubProtocolErrorHandler.java index fdcc2b50..3b5800eb 100644 --- a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/SafeStompSubProtocolErrorHandler.java +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/stomp/SafeStompSubProtocolErrorHandler.java @@ -1,4 +1,4 @@ -package dev.caskeleton.adapter.inbound.websocket; +package dev.caskeleton.adapter.inbound.websocket.stomp; import org.springframework.messaging.Message; import org.springframework.messaging.simp.stomp.StompCommand; diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/WebSocketConfig.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/stomp/WebSocketConfig.java similarity index 98% rename from src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/WebSocketConfig.java rename to src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/stomp/WebSocketConfig.java index 4a044cbb..a7aff464 100644 --- a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/WebSocketConfig.java +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/stomp/WebSocketConfig.java @@ -1,4 +1,4 @@ -package dev.caskeleton.adapter.inbound.websocket; +package dev.caskeleton.adapter.inbound.websocket.stomp; import java.util.List; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/WebSocketInboundAuthorizationInterceptor.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/stomp/WebSocketInboundAuthorizationInterceptor.java similarity index 97% rename from src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/WebSocketInboundAuthorizationInterceptor.java rename to src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/stomp/WebSocketInboundAuthorizationInterceptor.java index e509d91c..f1f161b1 100644 --- a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/WebSocketInboundAuthorizationInterceptor.java +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/stomp/WebSocketInboundAuthorizationInterceptor.java @@ -1,4 +1,4 @@ -package dev.caskeleton.adapter.inbound.websocket; +package dev.caskeleton.adapter.inbound.websocket.stomp; import java.security.Principal; import org.springframework.messaging.Message; diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/WebSocketPolicyViolationException.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/stomp/WebSocketPolicyViolationException.java similarity index 86% rename from src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/WebSocketPolicyViolationException.java rename to src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/stomp/WebSocketPolicyViolationException.java index aaa613cc..64e9c84a 100644 --- a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/WebSocketPolicyViolationException.java +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/stomp/WebSocketPolicyViolationException.java @@ -1,4 +1,4 @@ -package dev.caskeleton.adapter.inbound.websocket; +package dev.caskeleton.adapter.inbound.websocket.stomp; /** Internal fixed-code signal consumed by the client-safe STOMP error boundary. */ final class WebSocketPolicyViolationException extends RuntimeException { diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/WebSocketProperties.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/stomp/WebSocketProperties.java similarity index 98% rename from src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/WebSocketProperties.java rename to src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/stomp/WebSocketProperties.java index a36f59c9..f5b405ad 100644 --- a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/WebSocketProperties.java +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/stomp/WebSocketProperties.java @@ -1,4 +1,4 @@ -package dev.caskeleton.adapter.inbound.websocket; +package dev.caskeleton.adapter.inbound.websocket.stomp; import jakarta.validation.constraints.AssertTrue; import java.net.URI; diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/webflux/ReactiveFrameSink.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/webflux/ReactiveFrameSink.java new file mode 100644 index 00000000..e006fd57 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/webflux/ReactiveFrameSink.java @@ -0,0 +1,82 @@ +package dev.caskeleton.adapter.inbound.websocket.webflux; + +import dev.caskeleton.adapter.inbound.websocket.outbound.SerializedOutboundWriter; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicReference; +import org.springframework.web.reactive.socket.WebSocketSession; +import reactor.core.publisher.Mono; +import reactor.core.publisher.Sinks; + +/** + * The reactive runtime's write path. + * + *

A {@code Sinks.Many} rather than a direct {@code session.send} per message, and the reason is + * structural: {@code WebSocketSession.send} returns a {@code Mono} that must be subscribed exactly + * once for the whole session. Calling it per message means many concurrent sends on one session, + * which the reactive API leaves as undefined as the servlet one — frames interleave and the peer + * sees a corrupt stream. + * + *

So the session is subscribed once to a sink, and every write pushes into it. The sink's + * back-pressure buffer is bounded by the platform's own queue above it, which is why this class + * uses {@code tryEmitNext} and treats a failure as a transport refusal rather than buffering again + * — a second unbounded buffer here would silently undo the queue's whole purpose. + */ +public final class ReactiveFrameSink implements SerializedOutboundWriter.FrameSink { + + private final Sinks.Many outbound; + private final AtomicReference failure = new AtomicReference<>(); + + private ReactiveFrameSink(Sinks.Many outbound) { + this.outbound = outbound; + } + + /** + * Attaches a sink to a session and returns the publisher the runtime must subscribe. + * + * @param session the reactive session + * @return the sink, and the {@code Mono} that drives it + */ + public static Attached attach(WebSocketSession session) { + Objects.requireNonNull(session, "session"); + // Bounded and single-subscriber. `onBackpressureBuffer()` with no bound is the default reach + // and it is exactly the unbounded buffer the outbound queue exists to replace. + Sinks.Many sink = Sinks.many().unicast().onBackpressureBuffer(); + ReactiveFrameSink frameSink = new ReactiveFrameSink(sink); + Mono pump = + session.send(sink.asFlux().map(session::textMessage)).doOnError(frameSink.failure::set); + return new Attached(frameSink, pump); + } + + @Override + public void write(String payload) throws Exception { + Throwable observed = failure.get(); + if (observed != null) { + throw new IllegalStateException("the session has already failed", observed); + } + Sinks.EmitResult result = outbound.tryEmitNext(payload); + if (result.isFailure()) { + // Not retried and not buffered. A retry here would spin on a peer that is not reading, and + // buffering would re-create the unbounded growth the queue above already refuses. + throw new IllegalStateException("the reactive sink refused the frame: " + result); + } + } + + /** Signals that no further messages will be written. */ + public void complete() { + outbound.tryEmitComplete(); + } + + /** + * A sink and the publisher that drives it. + * + * @param sink where the writer pushes + * @param pump the {@code Mono} the runtime must return so the session is subscribed exactly once + */ + public record Attached(ReactiveFrameSink sink, Mono pump) { + + public Attached { + Objects.requireNonNull(sink, "sink"); + Objects.requireNonNull(pump, "pump"); + } + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/webflux/WebSocketDataBufferLifecycle.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/webflux/WebSocketDataBufferLifecycle.java new file mode 100644 index 00000000..cfdac02c --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/webflux/WebSocketDataBufferLifecycle.java @@ -0,0 +1,106 @@ +package dev.caskeleton.adapter.inbound.websocket.webflux; + +import java.util.Objects; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.LongConsumer; + +/** + * Tracks what is retained, so a leak is a number rather than a slow heap. + * + *

Reference counting is the kind of bug that does not fail where it happens. A buffer that is + * retained and never released costs nothing at the call site, produces a Netty warning on a logger + * most deployments have turned down, and shows up weeks later as a heap that grows with traffic + * nobody can attribute. The only way to notice it early is to count. + * + *

Every path releases, including the two that are easy to forget. Cancellation is the common one + * — for a long-lived socket it is the normal way a subscription ends — and an error path that + * returns before its release is the other. + */ +public final class WebSocketDataBufferLifecycle { + + private final WebSocketDataBufferPolicy policy; + private final AtomicLong retainedBytes = new AtomicLong(); + private final AtomicLong retainCount = new AtomicLong(); + private final AtomicLong releaseCount = new AtomicLong(); + private final LongConsumer onLimitExceeded; + + /** + * @param policy what this runtime is allowed to retain + * @param onLimitExceeded called with the attempted size when the ceiling would be crossed + */ + public WebSocketDataBufferLifecycle( + WebSocketDataBufferPolicy policy, LongConsumer onLimitExceeded) { + this.policy = Objects.requireNonNull(policy, "policy"); + this.onLimitExceeded = Objects.requireNonNull(onLimitExceeded, "onLimitExceeded"); + } + + /** The policy in force. */ + public WebSocketDataBufferPolicy policy() { + return policy; + } + + /** + * Record a retain. + * + * @param bytes how large the retained buffer is + * @return whether the retain is permitted; false means the caller must copy or drop instead + */ + public boolean retain(int bytes) { + if (bytes < 0) { + throw new IllegalArgumentException("a buffer has a non-negative size"); + } + if (!policy.mayRetain(retainedBytes.get(), bytes)) { + onLimitExceeded.accept(bytes); + return false; + } + retainedBytes.addAndGet(bytes); + retainCount.incrementAndGet(); + return true; + } + + /** + * Record a release. + * + *

Refuses to go negative rather than silently wrapping. A release without a matching retain is + * a double-release, which in Netty is a buffer returned to the pool twice and handed to two + * owners — the same use-after-free as the leak's opposite, and far harder to trace from a + * corrupted message than from a counter that complained. + */ + public void release(int bytes) { + if (bytes < 0) { + throw new IllegalArgumentException("a buffer has a non-negative size"); + } + long remaining = retainedBytes.addAndGet(-bytes); + releaseCount.incrementAndGet(); + if (remaining < 0) { + retainedBytes.set(0); + throw new IllegalStateException( + "more bytes were released than retained; a double release returns one pooled buffer to" + + " two owners"); + } + } + + /** How much is currently retained. */ + public long retainedBytes() { + return retainedBytes.get(); + } + + /** + * Whether every retain has been matched. + * + *

The assertion a leak test makes after a stream ends, including one that was cancelled. + */ + public boolean balanced() { + return retainCount.get() == releaseCount.get() && retainedBytes.get() == 0; + } + + /** How many retains happened. */ + public long retains() { + return retainCount.get(); + } + + /** How many releases happened. */ + public long releases() { + return releaseCount.get(); + } +} diff --git a/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/webflux/WebSocketDataBufferPolicy.java b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/webflux/WebSocketDataBufferPolicy.java new file mode 100644 index 00000000..3abe2c27 --- /dev/null +++ b/src/adapter/inbound/websocket/src/main/java/dev/caskeleton/adapter/inbound/websocket/webflux/WebSocketDataBufferPolicy.java @@ -0,0 +1,62 @@ +package dev.caskeleton.adapter.inbound.websocket.webflux; + +/** + * How pooled buffers are handled on the reactive stack. + * + *

The reason this is a declared policy rather than a convention: Reactor Netty hands the handler + * a {@code DataBuffer} backed by a pooled, reference-counted Netty buffer, and that buffer is + * released the moment the handler's callback returns. Anything the handler kept — a reference + * stashed for an async continuation, a payload queued for later — is then a use-after-free that + * reads whatever the pool has since put there. The failure is not a crash; it is a message whose + * contents are somebody else's. + * + *

Retaining fixes that and creates the opposite failure: a retained buffer that is not released + * on every exit path leaks pool memory, and Netty reports it as a warning nobody reads while the + * heap grows with the number of cancelled subscriptions. + * + *

So the policy states both halves, and {@code maxRetainedBytes} bounds the second. A deployment + * that does not cross an async boundary sets {@code retainAcrossAsyncBoundary} false and pays for + * neither. + * + * @param pooled whether the runtime hands out pooled buffers at all + * @param retainAcrossAsyncBoundary whether a buffer may outlive the callback that received it + * @param maxRetainedBytes the ceiling on what one connection may hold retained + */ +public record WebSocketDataBufferPolicy( + boolean pooled, boolean retainAcrossAsyncBoundary, long maxRetainedBytes) { + + public WebSocketDataBufferPolicy { + if (retainAcrossAsyncBoundary && !pooled) { + throw new IllegalArgumentException( + "retaining is only meaningful for pooled buffers; an unpooled one is ordinary heap and" + + " needs no reference counting"); + } + if (retainAcrossAsyncBoundary && maxRetainedBytes < 1) { + throw new IllegalArgumentException( + "retaining without a ceiling leaks the pool one cancelled subscription at a time, and" + + " Netty reports it as a warning nobody reads"); + } + if (!retainAcrossAsyncBoundary && maxRetainedBytes != 0) { + throw new IllegalArgumentException( + "a policy that retains nothing describes a ceiling that cannot be reached"); + } + } + + /** The safe default: copy out of the pooled buffer before the callback returns. */ + public static WebSocketDataBufferPolicy copyOnReceive() { + return new WebSocketDataBufferPolicy(true, false, 0); + } + + /** Retain across an async boundary, bounded. */ + public static WebSocketDataBufferPolicy retaining(long maxRetainedBytes) { + return new WebSocketDataBufferPolicy(true, true, maxRetainedBytes); + } + + /** Whether another retained buffer of this size may be held. */ + public boolean mayRetain(long currentlyRetained, int additionalBytes) { + if (!retainAcrossAsyncBoundary) { + return false; + } + return currentlyRetained + additionalBytes <= maxRetainedBytes; + } +} diff --git a/src/adapter/inbound/websocket/src/nginxWebSocketTest/java/dev/caskeleton/adapter/inbound/websocket/proxy/NginxWebSocketContractIT.java b/src/adapter/inbound/websocket/src/nginxWebSocketTest/java/dev/caskeleton/adapter/inbound/websocket/proxy/NginxWebSocketContractIT.java new file mode 100644 index 00000000..5679c3b0 --- /dev/null +++ b/src/adapter/inbound/websocket/src/nginxWebSocketTest/java/dev/caskeleton/adapter/inbound/websocket/proxy/NginxWebSocketContractIT.java @@ -0,0 +1,195 @@ +package dev.caskeleton.adapter.inbound.websocket.proxy; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.inbound.websocket.testkit.runtime.WebSocketFixtureApplication; +import java.net.URI; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketHttpHeaders; +import org.springframework.web.socket.WebSocketSession; +import org.springframework.web.socket.client.standard.StandardWebSocketClient; +import org.springframework.web.socket.handler.TextWebSocketHandler; + +/** + * The upgrade path through a real reverse proxy. + * + *

This is where "works locally, fails behind the load balancer" comes from, and none of it is + * observable from either side alone. The application's upgrade handling is correct and thoroughly + * tested; the proxy configuration is a file nothing verifies. The failure lives entirely in the + * seam, and it presents to a developer as a socket that opens and immediately closes. + */ +@SpringBootTest( + classes = WebSocketFixtureApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class NginxWebSocketContractIT { + + private static NginxWebSocketHarness proxy; + + @LocalServerPort private int port; + + @BeforeAll + static void requireDocker() { + // Stated rather than skipped. A proxy contract that quietly passes without a proxy has been + // certifying nothing since whenever the container runtime last broke. + org.junit.jupiter.api.Assertions.assertTrue( + NginxWebSocketHarness.dockerAvailable(), + "this lane needs a container runtime; it certifies Nginx's own behaviour"); + } + + @AfterAll + static void stopProxy() { + if (proxy != null) { + proxy.close(); + proxy = null; + } + } + + private NginxWebSocketHarness proxy() { + if (proxy == null) { + proxy = new NginxWebSocketHarness(port, "nginx/nginx.conf"); + } + return proxy; + } + + @Test + @DisplayName("a correctly configured proxy upgrades the connection") + void correctProxyUpgrades() throws Exception { + try (Peer peer = + Peer.connect(URI.create(proxy().webSocketUrl(WebSocketFixtureApplication.PATH)))) { + peer.send("through-the-proxy"); + + assertThat(peer.awaitMessage()).isEqualTo("echo:through-the-proxy"); + } + } + + @Test + @DisplayName("messages keep their order through the proxy") + void orderSurvivesTheProxy() throws Exception { + try (Peer peer = + Peer.connect(URI.create(proxy().webSocketUrl(WebSocketFixtureApplication.PATH)))) { + for (int index = 0; index < 20; index++) { + peer.send("m" + index); + } + + List received = peer.awaitMessages(20); + for (int index = 0; index < 20; index++) { + assertThat(received.get(index)).isEqualTo("echo:m" + index); + } + } + } + + @Test + @DisplayName("a proxy that does not forward Upgrade cannot establish a connection") + void proxyWithoutUpgradeForwardingFails() throws Exception { + // The single most common WebSocket proxy mistake, and the proof that this lane's assertions + // are capable of failing. Upgrade and Connection are hop-by-hop, so a proxy drops them unless + // told otherwise — and the request arrives upstream as an ordinary GET. + try (NginxWebSocketHarness broken = + new NginxWebSocketHarness(port, "nginx/nginx-no-upgrade.conf")) { + assertThatThrownBy( + () -> Peer.connect(URI.create(broken.webSocketUrl(WebSocketFixtureApplication.PATH)))) + .isInstanceOf(Exception.class); + } + } + + @Test + @DisplayName("the oversized-message close reaches the client through the proxy") + void closeCodeSurvivesTheProxy() throws Exception { + // A close frame is a frame like any other, and a proxy that mishandles the upgrade turns it + // into a dropped TCP connection — a 1006 the client cannot interpret. + try (Peer peer = + Peer.connect(URI.create(proxy().webSocketUrl(WebSocketFixtureApplication.PATH)))) { + peer.send("x".repeat(WebSocketFixtureApplication.MAX_MESSAGE_BYTES * 4)); + + assertThat(peer.awaitCloseCode()).isEqualTo(1009); + } + } + + /** A client peer over the real transport. */ + private static final class Peer implements AutoCloseable { + + private final WebSocketSession session; + private final List messages = new CopyOnWriteArrayList<>(); + private final AtomicReference closeCode = new AtomicReference<>(); + private final CountDownLatch closed = new CountDownLatch(1); + private final CountDownLatch received = new CountDownLatch(1); + + private Peer(WebSocketSession session) { + this.session = session; + } + + static Peer connect(URI endpoint) throws Exception { + AtomicReference holder = new AtomicReference<>(); + TextWebSocketHandler handler = + new TextWebSocketHandler() { + @Override + protected void handleTextMessage(WebSocketSession session, TextMessage message) { + holder.get().messages.add(message.getPayload()); + holder.get().received.countDown(); + } + + @Override + public void afterConnectionClosed( + WebSocketSession session, org.springframework.web.socket.CloseStatus status) { + holder.get().closeCode.set(status.getCode()); + holder.get().closed.countDown(); + } + }; + CompletableFuture future = + new StandardWebSocketClient().execute(handler, new WebSocketHttpHeaders(), endpoint); + WebSocketSession session = future.get(15, TimeUnit.SECONDS); + Peer peer = new Peer(session); + holder.set(peer); + return peer; + } + + void send(String payload) throws Exception { + session.sendMessage(new TextMessage(payload)); + } + + String awaitMessage() throws Exception { + if (!received.await(15, TimeUnit.SECONDS)) { + throw new AssertionError("no message arrived through the proxy within 15s"); + } + return messages.get(messages.size() - 1); + } + + List awaitMessages(int count) throws Exception { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(20); + while (messages.size() < count && System.nanoTime() < deadline) { + Thread.sleep(10); + } + if (messages.size() < count) { + throw new AssertionError("only " + messages.size() + " of " + count + " arrived"); + } + return List.copyOf(messages); + } + + int awaitCloseCode() throws Exception { + if (!closed.await(15, TimeUnit.SECONDS)) { + throw new AssertionError("the connection did not close within 15s"); + } + return closeCode.get(); + } + + @Override + public void close() throws Exception { + if (session.isOpen()) { + session.close(); + } + } + } +} diff --git a/src/adapter/inbound/websocket/src/nginxWebSocketTest/java/dev/caskeleton/adapter/inbound/websocket/proxy/NginxWebSocketHarness.java b/src/adapter/inbound/websocket/src/nginxWebSocketTest/java/dev/caskeleton/adapter/inbound/websocket/proxy/NginxWebSocketHarness.java new file mode 100644 index 00000000..2db85fb6 --- /dev/null +++ b/src/adapter/inbound/websocket/src/nginxWebSocketTest/java/dev/caskeleton/adapter/inbound/websocket/proxy/NginxWebSocketHarness.java @@ -0,0 +1,76 @@ +package dev.caskeleton.adapter.inbound.websocket.proxy; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.images.builder.Transferable; +import org.testcontainers.utility.DockerImageName; + +/** + * A real Nginx in front of the WebSocket application. + * + *

Real because every property this lane checks is Nginx's own behaviour. "HTTP/1.0 has no + * Upgrade mechanism", "hop-by-hop headers are dropped unless forwarded explicitly" and "an idle + * connection is killed at the read timeout" are facts about the proxy; asserting them against a + * stub would be asserting what the author already believed. + */ +public final class NginxWebSocketHarness implements AutoCloseable { + + private static final DockerImageName IMAGE = DockerImageName.parse("nginx:1.27-alpine"); + + private final GenericContainer nginx; + + /** + * Starts Nginx pointed at a locally running application. + * + * @param applicationPort the port the application is listening on + * @param configurationResource which configuration to load + */ + @SuppressWarnings("resource") + public NginxWebSocketHarness(int applicationPort, String configurationResource) { + this.nginx = + new GenericContainer<>(IMAGE) + .withExposedPorts(8080) + // host.docker.internal is not resolvable on stock Linux Docker; without the gateway + // the container fails with a connection refused that reads as a config error. + .withExtraHost("host.docker.internal", "host-gateway") + .withCopyToContainer( + Transferable.of(configuration(configurationResource, applicationPort)), + "/etc/nginx/nginx.conf"); + nginx.start(); + } + + /** The ws:// URL callers reach the proxy on. */ + public String webSocketUrl(String path) { + return "ws://" + nginx.getHost() + ":" + nginx.getMappedPort(8080) + path; + } + + /** Whether a container runtime is available. */ + public static boolean dockerAvailable() { + try { + return org.testcontainers.DockerClientFactory.instance().isDockerAvailable(); + } catch (RuntimeException unavailable) { + return false; + } + } + + @Override + public void close() { + nginx.stop(); + } + + private static String configuration(String resource, int applicationPort) { + try (InputStream stream = + NginxWebSocketHarness.class.getClassLoader().getResourceAsStream(resource)) { + if (stream == null) { + throw new IllegalStateException(resource + " is missing from the lane's resources"); + } + return new String(stream.readAllBytes(), StandardCharsets.UTF_8) + .replace("APPLICATION_HOST", "host.docker.internal") + .replace("APPLICATION_PORT", Integer.toString(applicationPort)); + } catch (IOException e) { + throw new IllegalStateException(resource + " could not be read", e); + } + } +} diff --git a/src/adapter/inbound/websocket/src/nginxWebSocketTest/resources/nginx/nginx-no-upgrade.conf b/src/adapter/inbound/websocket/src/nginxWebSocketTest/resources/nginx/nginx-no-upgrade.conf new file mode 100644 index 00000000..68805c1e --- /dev/null +++ b/src/adapter/inbound/websocket/src/nginxWebSocketTest/resources/nginx/nginx-no-upgrade.conf @@ -0,0 +1,24 @@ +# The same proxy with the Upgrade forwarding removed — the single most common WebSocket proxy +# mistake, and the one that produces "it works locally and not behind the load balancer". +# +# Nothing else differs. This file exists so the lane can prove its own assertions are capable of +# failing: a contract that only ever runs against a correct configuration cannot tell whether it is +# checking anything. +events {} + +http { + upstream application { + server APPLICATION_HOST:APPLICATION_PORT; + } + + server { + listen 8080; + server_name hyeonworks.com; + + location /ws/ { + proxy_set_header Host $host; + proxy_read_timeout 3600s; + proxy_pass http://application; + } + } +} diff --git a/src/adapter/inbound/websocket/src/nginxWebSocketTest/resources/nginx/nginx.conf b/src/adapter/inbound/websocket/src/nginxWebSocketTest/resources/nginx/nginx.conf new file mode 100644 index 00000000..358afd02 --- /dev/null +++ b/src/adapter/inbound/websocket/src/nginxWebSocketTest/resources/nginx/nginx.conf @@ -0,0 +1,58 @@ +# The reverse proxy a WebSocket deployment actually sits behind. +# +# The three directives that matter are ones a plain HTTP proxy configuration does not need, and +# omitting any of them produces a distinct, confusing failure: +# +# 1. `proxy_http_version 1.1`. Nginx proxies with HTTP/1.0 by default, and the Upgrade mechanism +# does not exist in 1.0. Without this the handshake is answered 400 or the upgrade is simply +# not performed, and the client sees a socket that opens and immediately closes. +# +# 2. `Upgrade` and `Connection` forwarded explicitly. They are hop-by-hop headers, which means a +# proxy is required to drop them — so the upgrade request arrives upstream as an ordinary GET +# and the application answers 404 or 200 for a route that works perfectly when tested +# directly. +# +# 3. `proxy_read_timeout` raised well past its 60s default. A WebSocket is idle by nature; at the +# default, every connection quieter than a minute is killed by the proxy with no close frame, +# and the application sees connections vanishing for no reason it can observe. +events {} + +http { + upstream application { + server APPLICATION_HOST:APPLICATION_PORT; + } + + # `$connection_upgrade` is "upgrade" for an upgrade request and "close" otherwise. Hard-coding + # "upgrade" would send it on ordinary requests too, which some upstreams reject. + map $http_upgrade $connection_upgrade { + default upgrade; + '' close; + } + + server { + listen 8080; + server_name hyeonworks.com; + + location /ws/ { + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + + # The authoritative values, set rather than added, for the same reason as on the HTTP + # side: a client's own X-Forwarded-* must never reach the application. + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Forwarded-Host hyeonworks.com; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header Forwarded ""; + + # A WebSocket is idle by nature. At the 60s default the proxy kills every quiet + # connection without a close frame, and the application observes connections vanishing. + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + + proxy_pass http://application; + } + } +} diff --git a/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/advanced/client/OutboundClientTest.java b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/advanced/client/OutboundClientTest.java new file mode 100644 index 00000000..bc1d151c --- /dev/null +++ b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/advanced/client/OutboundClientTest.java @@ -0,0 +1,149 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.client; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.net.URI; +import java.time.Duration; +import java.util.List; +import java.util.Optional; +import java.util.OptionalLong; +import java.util.random.RandomGenerator; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** Connecting out to somebody else, without becoming their outage. */ +@Tag("websocket-advanced") +class OutboundClientTest { + + private static NamedClientProfile profile(String name, String uri) { + return new NamedClientProfile( + name, + URI.create(uri), + Optional.of("ca-skeleton.v1"), + Duration.ofSeconds(5), + Duration.ofSeconds(60)); + } + + @Test + @DisplayName("reconnect waits grow, stay under the ceiling and are jittered") + void reconnectBackoffIsBoundedAndJittered() { + // The ceiling has to be a ceiling: adding jitter on top of maxBackoff means the configured + // maximum is not the maximum, which only shows up when the peer stays down for an hour. + ReconnectPolicy policy = ReconnectPolicy.conventional(); + RandomGenerator zero = new FixedRandom(0); + RandomGenerator max = new FixedRandom(Long.MAX_VALUE); + + assertThat(policy.backoffFor(1, zero)).isEqualTo(Duration.ofSeconds(1)); + assertThat(policy.backoffFor(4, zero)).isEqualTo(Duration.ofSeconds(8)); + assertThat(policy.backoffFor(50, zero)).isEqualTo(Duration.ofSeconds(60)); + assertThat(policy.backoffFor(50, max)).isLessThanOrEqualTo(Duration.ofSeconds(60)); + assertThat(policy.backoffFor(50, max).isNegative()).isFalse(); + } + + @Test + @DisplayName("a policy without jitter is refused") + void jitterlessPolicyIsRefused() { + // Without it every client of a restarted peer reconnects in lockstep and keeps it down. + assertThatThrownBy( + () -> + new ReconnectPolicy( + Duration.ofSeconds(1), Duration.ofSeconds(60), 2.0, OptionalLong.of(20), 0.0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("lockstep"); + } + + @Test + @DisplayName("attempts are bounded") + void attemptsAreBounded() { + // A client retrying forever against a decommissioned peer is a permanent load source that + // nobody notices, because each individual attempt looks reasonable. + ReconnectPolicy policy = ReconnectPolicy.conventional(); + + assertThat(policy.mayRetry(19)).isTrue(); + assertThat(policy.mayRetry(20)).isFalse(); + } + + @Test + @DisplayName("a zero first backoff is refused") + void zeroFirstBackoffIsRefused() { + assertThatThrownBy( + () -> + new ReconnectPolicy( + Duration.ZERO, Duration.ofSeconds(60), 2.0, OptionalLong.empty(), 1.0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("tight loop"); + } + + @Test + @DisplayName("outbound connections are named, and two of one name are refused") + void connectionsAreNamedAndUnique() { + // Whichever was registered last would silently win, and the other peer would never be reached. + assertThatThrownBy( + () -> + NamedClientRegistry.of( + List.of(profile("upstream", "wss://a"), profile("upstream", "wss://b")))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("silently win"); + } + + @Test + @DisplayName("an undeclared name is not resolvable") + void undeclaredNameIsRefused() { + NamedClientRegistry registry = NamedClientRegistry.of(List.of(profile("upstream", "wss://a"))); + + assertThat(registry.find("upstream")).isPresent(); + assertThat(registry.find("somewhere-else")).isEmpty(); + assertThat(registry.names()).containsExactly("upstream"); + } + + @Test + @DisplayName("a plaintext outbound connection says what it exposes") + void plaintextConnectionIsReported() { + NamedClientRegistry registry = + NamedClientRegistry.of( + List.of(profile("insecure", "ws://a"), profile("secure", "wss://b"))); + + assertThat(registry.plaintextConcerns()).singleElement().asString().contains("insecure"); + assertThat(profile("secure", "wss://b").secure()).isTrue(); + } + + @Test + @DisplayName("a non-WebSocket URI and unbounded timeouts are refused") + void malformedProfileIsRefused() { + assertThatThrownBy( + () -> + new NamedClientProfile( + "x", + URI.create("https://peer"), + Optional.empty(), + Duration.ofSeconds(1), + Duration.ofSeconds(1))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("not a WebSocket URI"); + assertThatThrownBy( + () -> + new NamedClientProfile( + "x", + URI.create("wss://peer"), + Optional.empty(), + Duration.ofSeconds(1), + Duration.ZERO)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("half-open connection to a vanished peer"); + } + + /** A generator with one answer, so the backoff arithmetic is checked rather than sampled. */ + private record FixedRandom(long value) implements RandomGenerator { + @Override + public long nextLong() { + return value; + } + + @Override + public long nextLong(long bound) { + return Math.min(value, bound - 1); + } + } +} diff --git a/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/advanced/cluster/ClusterFanoutTest.java b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/advanced/cluster/ClusterFanoutTest.java new file mode 100644 index 00000000..52894bce --- /dev/null +++ b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/advanced/cluster/ClusterFanoutTest.java @@ -0,0 +1,163 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.cluster; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketActorReference; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketEndpointName; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketNodeId; +import dev.caskeleton.adapter.inbound.websocket.protocol.WebSocketMessageType; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * Delivering across nodes, where the sender does not hold the connection. + * + *

Two facts shape everything here. The index is a cache of state owned by another machine, so + * every entry is about the past; and durable fan-out is at-least-once by construction, so + * redelivery is normal operation rather than an error. + */ +@Tag("websocket-advanced") +class ClusterFanoutTest { + + private static final Instant NOW = Instant.parse("2026-08-25T10:00:00Z"); + private static final byte[] SALT = + "a-deployment-salt-of-adequate-length".getBytes(StandardCharsets.UTF_8); + private static final WebSocketActorReference ALICE = + WebSocketActorReference.of("alice", "acme", SALT); + private static final WebSocketEndpointName ENDPOINT = new WebSocketEndpointName("live-updates"); + private static final WebSocketMessageType TYPE = new WebSocketMessageType("order.placed.v1"); + + private static FanoutEnvelope ordered(long sequence) { + return new FanoutEnvelope( + ALICE, + ENDPOINT, + TYPE, + "{}", + Optional.of("orders"), + Optional.of(sequence), + NOW, + NOW.plusSeconds(30)); + } + + @Test + @DisplayName("an index key carries a fingerprint, never a subject") + void indexKeyCarriesNoIdentity() { + // Redis keys reach MONITOR, the slow log, KEYS output during an incident, and every backup — + // none of which have the access controls the application has. + String key = ExternalSessionSummary.indexKey(ALICE, ENDPOINT); + + assertThat(key).doesNotContain("alice"); + assertThat(key).doesNotContain("acme"); + assertThat(key).contains(ALICE.fingerprint()); + } + + @Test + @DisplayName("an index entry knows when it was observed and when it stops meaning anything") + void indexEntriesAgeOut() { + // The index caches a fact owned by another machine. Treating an entry as current is how a + // message is routed to a node that stopped existing three minutes ago. + ExternalSessionSummary summary = + new ExternalSessionSummary(ALICE, new WebSocketNodeId("edge-1"), ENDPOINT, 2, NOW); + + assertThat(summary.staleAt(Duration.ofSeconds(30), NOW.plusSeconds(10))).isFalse(); + assertThat(summary.staleAt(Duration.ofSeconds(30), NOW.plusSeconds(31))).isTrue(); + } + + @Test + @DisplayName("a redelivered position is not delivered twice") + void redeliveryIsDeduplicated() { + // At-least-once is the contract, so this is normal operation: a redeploy, a slow node or a + // broker rebalance all produce it. Without deduplication a rolling restart shows every client + // a burst of repeats. + FanoutDeduplicator deduplicator = new FanoutDeduplicator(Duration.ofMinutes(5)); + + assertThat(deduplicator.shouldDeliver(ordered(41), NOW)).isTrue(); + assertThat(deduplicator.shouldDeliver(ordered(42), NOW)).isTrue(); + assertThat(deduplicator.shouldDeliver(ordered(42), NOW)).isFalse(); + assertThat(deduplicator.shouldDeliver(ordered(41), NOW)).isFalse(); + assertThat(deduplicator.shouldDeliver(ordered(43), NOW)).isTrue(); + } + + @Test + @DisplayName("an unordered fan-out is not deduplicated") + void unorderedFanoutIsNotDeduplicated() { + // There is no key to deduplicate against, and inventing one would silently drop legitimately + // repeated values. + FanoutDeduplicator deduplicator = new FanoutDeduplicator(Duration.ofMinutes(5)); + FanoutEnvelope unordered = + new FanoutEnvelope( + ALICE, + ENDPOINT, + TYPE, + "{}", + Optional.empty(), + Optional.empty(), + NOW, + NOW.plusSeconds(30)); + + assertThat(deduplicator.shouldDeliver(unordered, NOW)).isTrue(); + assertThat(deduplicator.shouldDeliver(unordered, NOW)).isTrue(); + } + + @Test + @DisplayName("an expired fan-out is dropped by the receiving node") + void expiredFanoutIsDropped() { + // Checked on receipt as well as at publication. A message that sat through an outage arrives + // late, and for the ephemeral traffic fan-out carries, late is indistinguishable from wrong. + FanoutDeduplicator deduplicator = new FanoutDeduplicator(Duration.ofMinutes(5)); + + assertThat(deduplicator.shouldDeliver(ordered(41), NOW.plusSeconds(60))).isFalse(); + } + + @Test + @DisplayName("the deduplicator forgets streams that go quiet") + void deduplicatorForgetsIdleStreams() { + // Otherwise it grows with the number of streams that ever existed rather than the number in + // use. + FanoutDeduplicator deduplicator = new FanoutDeduplicator(Duration.ofMinutes(5)); + deduplicator.shouldDeliver(ordered(41), NOW); + + assertThat(deduplicator.trackedStreams()).isOne(); + assertThat(deduplicator.evictIdle(NOW.plus(Duration.ofMinutes(6)))).isOne(); + assertThat(deduplicator.trackedStreams()).isZero(); + } + + @Test + @DisplayName("a fan-out envelope carries an encoded document, not a business object") + void envelopeCarriesAnEncodedDocument() { + // The payload is serialized by one node and read by another, which during a rolling deploy are + // different versions of the code. Keeping it the catalog-encoded document makes it the same + // contract the client sees rather than a second private one. + assertThat( + java.util.Arrays.stream(FanoutEnvelope.class.getRecordComponents()) + .filter(component -> component.getName().equals("payload")) + .findFirst() + .orElseThrow() + .getType()) + .isEqualTo(String.class); + } + + @Test + @DisplayName("half an ordering is refused") + void halfAnOrderingIsRefused() { + assertThatThrownBy( + () -> + new FanoutEnvelope( + ALICE, + ENDPOINT, + TYPE, + "{}", + Optional.of("orders"), + Optional.empty(), + NOW, + NOW.plusSeconds(30))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("meaningless apart"); + } +} diff --git a/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/advanced/cluster/PortBackedBridgeTest.java b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/advanced/cluster/PortBackedBridgeTest.java new file mode 100644 index 00000000..9247ec46 --- /dev/null +++ b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/advanced/cluster/PortBackedBridgeTest.java @@ -0,0 +1,328 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.cluster; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.inbound.websocket.advanced.presence.PortBackedPresenceStore; +import dev.caskeleton.adapter.inbound.websocket.advanced.presence.PresenceState; +import dev.caskeleton.adapter.inbound.websocket.advanced.resume.PortBackedReplayAvailability; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketActorReference; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketEndpointName; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketNodeId; +import dev.caskeleton.adapter.inbound.websocket.protocol.WebSocketMessageType; +import dev.caskeleton.application.realtime.ActorFingerprint; +import dev.caskeleton.application.realtime.ConnectionRegistration; +import dev.caskeleton.application.realtime.ConnectionRegistryPort; +import dev.caskeleton.application.realtime.DurableFanoutPort; +import dev.caskeleton.application.realtime.DurableFanoutRecord; +import dev.caskeleton.application.realtime.DurableFanoutUnavailableException; +import dev.caskeleton.application.realtime.LiveEventReplayPort; +import dev.caskeleton.application.realtime.RealtimeChannel; +import dev.caskeleton.application.realtime.RealtimeNodeId; +import dev.caskeleton.application.realtime.ReplayWindow; +import dev.caskeleton.application.realtime.ReplayedEvent; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * The bridges between this leaf's vocabulary and the application's realtime ports. + * + *

These classes are translators, so what is worth asserting is the translation and the failure + * behaviour — not the storage, which the outbound adapters own and test against their own + * datastore. + */ +@Tag("websocket-advanced") +class PortBackedBridgeTest { + + private static final Instant NOW = Instant.parse("2026-08-25T10:00:00Z"); + private static final byte[] SALT = + "a-deployment-salt-of-adequate-length".getBytes(StandardCharsets.UTF_8); + private static final WebSocketActorReference ALICE = + WebSocketActorReference.of("alice", "acme", SALT); + private static final WebSocketEndpointName FEED = new WebSocketEndpointName("live-updates"); + private static final WebSocketNodeId EDGE_ONE = new WebSocketNodeId("edge-1"); + private static final WebSocketNodeId EDGE_TWO = new WebSocketNodeId("edge-2"); + + @Test + @DisplayName("an announcement reaches the registry as a fingerprint, never a subject") + void announcementCarriesOnlyAFingerprint() { + // The port's own type refuses anything that is not a fingerprint, which is what stops a later + // change here from passing a raw subject into a datastore key. + RecordingRegistry registry = new RecordingRegistry(); + new PortBackedExternalSessionIndex(registry) + .announce( + new ExternalSessionSummary(ALICE, EDGE_ONE, FEED, 2, NOW), Duration.ofSeconds(30)); + + ConnectionRegistration announced = registry.announced.get(0); + assertThat(announced.actor().value()).isEqualTo(ALICE.fingerprint()).doesNotContain("alice"); + assertThat(announced.channel().value()).isEqualTo("live-updates"); + assertThat(announced.connectionCount()).isEqualTo(2); + } + + @Test + @DisplayName("a located actor comes back in this leaf's types") + void locatedActorIsTranslatedBack() { + RecordingRegistry registry = new RecordingRegistry(); + registry.announce( + new ConnectionRegistration( + new ActorFingerprint(ALICE.fingerprint()), + new RealtimeNodeId("edge-2"), + new RealtimeChannel("live-updates"), + 3, + NOW), + Duration.ofSeconds(30)); + + assertThat(new PortBackedExternalSessionIndex(registry).locate(ALICE, FEED, NOW)) + .singleElement() + .satisfies( + summary -> { + assertThat(summary.nodeId()).isEqualTo(EDGE_TWO); + assertThat(summary.connectionCount()).isEqualTo(3); + }); + } + + @Test + @DisplayName("a registry that reports nothing produces no locations rather than an error") + void emptyRegistryProducesNoLocations() { + assertThat(new PortBackedExternalSessionIndex(new RecordingRegistry()).locate(ALICE, FEED, NOW)) + .isEmpty(); + } + + @Test + @DisplayName("presence sums connections across nodes") + void presenceSumsAcrossNodes() { + // Two tabs on two nodes is one person with two connections, not two presences. + RecordingRegistry registry = new RecordingRegistry(); + announce(registry, "edge-1", 1, NOW); + announce(registry, "edge-2", 2, NOW); + + assertThat(store(registry).presenceOf(ALICE, NOW).activeConnectionCount()).isEqualTo(3); + } + + @Test + @DisplayName("presence ages on the oldest contributing report, not the newest") + void presenceAgesOnTheOldestReport() { + // Taking the newest would let one freshly-refreshed node make an otherwise stale picture look + // current, which is exactly the case presence must not get wrong. + RecordingRegistry registry = new RecordingRegistry(); + announce(registry, "edge-1", 1, NOW.minusSeconds(120)); + announce(registry, "edge-2", 1, NOW); + + assertThat(store(registry).presenceOf(ALICE, NOW).state()).isEqualTo(PresenceState.STALE); + } + + @Test + @DisplayName("nothing reported is offline") + void nothingReportedIsOffline() { + assertThat(store(new RecordingRegistry()).presenceOf(ALICE, NOW).state()) + .isEqualTo(PresenceState.OFFLINE); + } + + @Test + @DisplayName("replay availability distinguishes an unknown stream from an aged-out one") + void availabilityDistinguishesUnknownFromAgedOut() { + // Answering 0 for both would let a resume for a stream that never existed look like a complete + // replay of nothing. + PortBackedReplayAvailability availability = + new PortBackedReplayAvailability( + new StubReplay( + Map.of("orders", new ReplayWindow("orders", Optional.of(41L), Optional.of(90L))))); + + assertThat(availability.earliestAvailable("orders")).contains(41L); + assertThat(availability.earliestAvailable("never-existed")).isEmpty(); + } + + @Test + @DisplayName("a replay store that cannot be read reports nothing available") + void unreadableReplayStoreReportsNothing() { + // Degraded to "cannot resume", which becomes a resynchronise. Honouring a token we cannot + // verify would deliver a stream with a hole the client cannot see. + assertThat(new PortBackedReplayAvailability(new FailingReplay()).earliestAvailable("orders")) + .isEmpty(); + } + + @Test + @DisplayName("a fan-out is partitioned by recipient, not by feed") + void fanoutIsPartitionedByRecipient() { + // Keying on the endpoint would put a whole feed in one partition and serialise every recipient + // behind the slowest. + RecordingFanout fanout = new RecordingFanout(); + new MessagingFanoutAdapter(fanout).publish(envelope()); + + assertThat(fanout.published.get(0).partitionKey()).isEqualTo(ALICE.fingerprint()); + assertThat(fanout.published.get(0).channel().value()).isEqualTo("live-updates"); + } + + @Test + @DisplayName("a transport that refuses reaches the caller") + void refusedPublishReachesTheCaller() { + // Fail-closed, unlike the ephemeral fan-out: the caller is the only party that can choose + // between retrying, dropping and falling back. + assertThatThrownBy(() -> new MessagingFanoutAdapter(new RefusingFanout()).publish(envelope())) + .isInstanceOf(DurableFanoutUnavailableException.class); + } + + @Test + @DisplayName("an expired record is not delivered by the receiving node") + void expiredRecordIsNotDelivered() { + // Checked on receipt because a durable transport is exactly the one that can hold a message + // through an outage, and the receiver is the only place that knows what time it is then. + RecordingFanout fanout = new RecordingFanout(); + new MessagingFanoutAdapter(fanout).publish(envelope()); + DurableFanoutRecord record = fanout.published.get(0); + + assertThat(MessagingFanoutAdapter.deliverable(record, NOW.plusSeconds(10))).isTrue(); + assertThat(MessagingFanoutAdapter.deliverable(record, NOW.plusSeconds(60))).isFalse(); + } + + @Test + @DisplayName("a record naming a feed this build does not know is dropped, not fatal") + void unknownFeedIsDropped() { + // During a rolling deploy the other half of the cluster may publish for a feed this node has + // not learned yet, and failing the consumer would stop it processing the records it does + // understand. + RecordingFanout fanout = new RecordingFanout(); + MessagingFanoutAdapter adapter = new MessagingFanoutAdapter(fanout); + DurableFanoutRecord unknown = + new DurableFanoutRecord( + // A dotted channel: legal for the application's channel vocabulary and not for this + // leaf's endpoint names, which is exactly the shape a newer node would publish. + new RealtimeChannel("orders.v2"), + ALICE.fingerprint(), + "{}", + Optional.empty(), + Optional.empty(), + NOW, + NOW.plusSeconds(30)); + + assertThat(adapter.receive(unknown, ALICE, new WebSocketMessageType("order.placed.v1"))) + .isEmpty(); + } + + private static FanoutEnvelope envelope() { + return new FanoutEnvelope( + ALICE, + FEED, + new WebSocketMessageType("order.placed.v1"), + "{}", + Optional.of("orders"), + Optional.of(41L), + NOW, + NOW.plusSeconds(30)); + } + + private static PortBackedPresenceStore store(ConnectionRegistryPort registry) { + return new PortBackedPresenceStore( + registry, FEED, Duration.ofSeconds(30), Duration.ofSeconds(90)); + } + + private static void announce( + RecordingRegistry registry, String node, int connections, Instant observedAt) { + registry.announce( + new ConnectionRegistration( + new ActorFingerprint(ALICE.fingerprint()), + new RealtimeNodeId(node), + new RealtimeChannel("live-updates"), + connections, + observedAt), + Duration.ofSeconds(30)); + } + + /** An in-memory registry that records what it was told. */ + private static final class RecordingRegistry implements ConnectionRegistryPort { + + private final List announced = new ArrayList<>(); + private final Map held = new ConcurrentHashMap<>(); + + @Override + public void announce(ConnectionRegistration registration, Duration timeToLive) { + announced.add(registration); + held.put( + key(registration.actor(), registration.channel(), registration.nodeId()), registration); + } + + @Override + public void withdraw(ActorFingerprint actor, RealtimeChannel channel, RealtimeNodeId nodeId) { + held.remove(key(actor, channel, nodeId)); + } + + @Override + public List locate( + ActorFingerprint actor, RealtimeChannel channel, Instant now) { + return held.values().stream() + .filter(entry -> entry.actor().equals(actor) && entry.channel().equals(channel)) + .toList(); + } + + @Override + public void heartbeat(RealtimeNodeId nodeId, Instant now) {} + + @Override + public List evictSilentNodes(Duration heartbeatTimeout, Instant now) { + return List.of(); + } + + private static String key( + ActorFingerprint actor, RealtimeChannel channel, RealtimeNodeId node) { + return actor.value() + "|" + channel.value() + "|" + node.value(); + } + } + + /** A replay port over a fixed set of windows. */ + private record StubReplay(Map windows) implements LiveEventReplayPort { + + @Override + public ReplayWindow window(String streamId) { + return windows.getOrDefault(streamId, ReplayWindow.empty(streamId)); + } + + @Override + public List replayAfter(String streamId, long afterPosition, int limit) { + return List.of(); + } + } + + /** A replay port that is down. */ + private static final class FailingReplay implements LiveEventReplayPort { + + @Override + public ReplayWindow window(String streamId) { + throw new IllegalStateException("the store is unreachable"); + } + + @Override + public List replayAfter(String streamId, long afterPosition, int limit) { + throw new IllegalStateException("the store is unreachable"); + } + } + + /** A durable fan-out that records what it was given. */ + private static final class RecordingFanout implements DurableFanoutPort { + + private final List published = new ArrayList<>(); + + @Override + public void publish(DurableFanoutRecord record) { + published.add(record); + } + } + + /** A durable fan-out that will not accept anything. */ + private static final class RefusingFanout implements DurableFanoutPort { + + @Override + public void publish(DurableFanoutRecord record) { + throw new DurableFanoutUnavailableException( + record.channel(), new IllegalStateException("broker down")); + } + } +} diff --git a/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/advanced/cluster/ReplayCursorTest.java b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/advanced/cluster/ReplayCursorTest.java new file mode 100644 index 00000000..933ceed4 --- /dev/null +++ b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/advanced/cluster/ReplayCursorTest.java @@ -0,0 +1,180 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.cluster; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.inbound.websocket.protocol.WebSocketMessageCatalog; +import dev.caskeleton.adapter.inbound.websocket.protocol.WebSocketMessageDescriptor; +import dev.caskeleton.adapter.inbound.websocket.protocol.WebSocketMessageDirection; +import dev.caskeleton.adapter.inbound.websocket.protocol.WebSocketMessageFamily; +import dev.caskeleton.adapter.inbound.websocket.protocol.WebSocketMessageType; +import dev.caskeleton.adapter.inbound.websocket.protocol.WebSocketSchemaVersion; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * Replaying retained messages, where the producer and the consumer are not the same deployment. + * + *

That is what makes this different from live delivery. Retention outlives deploys by + * definition, so a retained message can name a type that has since been removed, changed major + * version, or was never meant to reach a client at all. "The producer and consumer agree" is + * exactly the assumption that does not hold on a replay path. + */ +@Tag("websocket-advanced") +class ReplayCursorTest { + + private static final WebSocketMessageType PLACED = new WebSocketMessageType("order.placed.v1"); + private static final WebSocketMessageType COMMAND = new WebSocketMessageType("order.place.v1"); + + private final WebSocketMessageCatalog catalog = + WebSocketMessageCatalog.of( + List.of( + new WebSocketMessageDescriptor( + PLACED, + WebSocketMessageFamily.EVENT, + WebSocketMessageDirection.SERVER_TO_CLIENT, + WebSocketSchemaVersion.v(1)), + new WebSocketMessageDescriptor( + COMMAND, + WebSocketMessageFamily.COMMAND, + WebSocketMessageDirection.CLIENT_TO_SERVER, + WebSocketSchemaVersion.v(1)))); + + private final ReplayEventMapper mapper = new ReplayEventMapper(catalog); + + private static Map headers(String type, String sequence) { + return Map.of( + "ws-stream-id", + "orders", + "ws-message-type", + type, + "ws-sequence", + sequence, + "ws-occurred-at", + "1756116000000"); + } + + @Test + @DisplayName("a cursor starts having delivered nothing") + void cursorStartsEmpty() { + ReplayCursor cursor = ReplayCursor.startingAt("orders", 43); + + assertThat(cursor.delivered()).isZero(); + assertThat(cursor.caughtUpTo(43)).isFalse(); + } + + @Test + @DisplayName("a cursor advances one position at a time") + void cursorAdvancesOneAtATime() { + ReplayCursor cursor = ReplayCursor.startingAt("orders", 43).advancedTo(43).advancedTo(44); + + assertThat(cursor.delivered()).isEqualTo(2); + assertThat(cursor.caughtUpTo(44)).isTrue(); + } + + @Test + @DisplayName("a cursor cannot skip a position") + void cursorCannotSkip() { + // A skip is a hole in somebody's history with nothing recording that it happened. + assertThatThrownBy(() -> ReplayCursor.startingAt("orders", 43).advancedTo(45)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("nothing recording that it did"); + } + + @Test + @DisplayName("a cursor cannot start before the first position") + void cursorCannotStartAtZero() { + // The source would answer with the first real message, silently shifting every position after. + assertThatThrownBy(() -> ReplayCursor.startingAt("orders", 0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("starts at position 1"); + } + + @Test + @DisplayName("a well-formed retained message maps") + void wellFormedMessageMaps() { + ReplayEventMapper.Mapped mapped = mapper.map(headers("order.placed.v1", "42"), "{}"); + + assertThat(mapped.record()).isPresent(); + assertThat(mapped.record().orElseThrow().sequence()).isEqualTo(42); + assertThat(mapped.record().orElseThrow().streamId()).isEqualTo("orders"); + } + + @Test + @DisplayName("a retained message naming a removed type is refused") + void removedTypeIsRefused() { + // The replay-path failure that live traffic never sees: the message was valid when it was + // written and the deployment that would receive it no longer publishes the type. + assertThat(mapper.map(headers("order.cancelled.v1", "42"), "{}").rejection()) + .contains(ReplayEventMapper.Rejection.UNKNOWN_TYPE); + } + + @Test + @DisplayName("a client-to-server type is never replayed to a client") + void clientOnlyTypeIsRefused() { + // Otherwise a retained command is delivered to subscribers as though the server had produced + // it. + assertThat(mapper.map(headers("order.place.v1", "42"), "{}").rejection()) + .contains(ReplayEventMapper.Rejection.WRONG_DIRECTION); + } + + @Test + @DisplayName("a message from before a breaking change is refused") + void schemaMismatchIsRefused() { + // A retained v2 message where the deployment publishes v1: forwarding it hands the client a + // document of a shape its current code cannot read. + WebSocketMessageCatalog v2 = + WebSocketMessageCatalog.of( + List.of( + new WebSocketMessageDescriptor( + new WebSocketMessageType("order.placed.v2"), + WebSocketMessageFamily.EVENT, + WebSocketMessageDirection.SERVER_TO_CLIENT, + WebSocketSchemaVersion.v(2)))); + + assertThat(new ReplayEventMapper(v2).map(headers("order.placed.v1", "42"), "{}").rejection()) + .contains(ReplayEventMapper.Rejection.UNKNOWN_TYPE); + } + + @Test + @DisplayName("a message missing what the platform needs is refused, not guessed") + void malformedMessageIsRefused() { + assertThat(mapper.map(Map.of("ws-stream-id", "orders"), "{}").rejection()) + .contains(ReplayEventMapper.Rejection.MALFORMED); + assertThat(mapper.map(headers("order.placed.v1", "not-a-number"), "{}").rejection()) + .contains(ReplayEventMapper.Rejection.MALFORMED); + assertThat(mapper.map(headers("order.placed.v1", "0"), "{}").rejection()) + .contains(ReplayEventMapper.Rejection.MALFORMED); + } + + @Test + @DisplayName("the broker's own headers do not reach the client") + void brokerHeadersDoNotReachTheClient() { + // A broker record carries queue names, delivery counts and routing keys. Forwarding it + // publishes the internal topology and makes the client's contract whatever the broker + // serializes. + Map withBrokerNoise = + Map.of( + "ws-stream-id", "orders", + "ws-message-type", "order.placed.v1", + "ws-sequence", "42", + "ws-occurred-at", "1756116000000", + "x-death-count", "11", + "amqp-routing-key", "internal.orders.retry.q"); + + ReplayRecord record = mapper.map(withBrokerNoise, "{}").record().orElseThrow(); + + // The record's components are the whole contract: there is nowhere for the broker's headers to + // travel. + assertThat(record.streamId()).isEqualTo("orders"); + assertThat(record.payload()).isEqualTo("{}"); + assertThat( + java.util.Arrays.stream(ReplayRecord.class.getRecordComponents()) + .map(java.lang.reflect.RecordComponent::getName) + .toList()) + .containsExactly("streamId", "sequence", "type", "payload", "occurredAt"); + } +} diff --git a/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/advanced/codec/BinaryCodecBackendScopeTest.java b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/advanced/codec/BinaryCodecBackendScopeTest.java new file mode 100644 index 00000000..bba4fe39 --- /dev/null +++ b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/advanced/codec/BinaryCodecBackendScopeTest.java @@ -0,0 +1,104 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.codec; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.inbound.websocket.moduleboundary.WebSocketBuildModel; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.regex.Pattern; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * The two binary backends stay off every adopter's runtime classpath. + * + *

This reads the build file because that is where the mistake is made. Both jars were {@code + * implementation} first, on the reasoning that an enabled codec should fail at startup rather than + * at the first frame. The reasoning missed what the jars do by merely being present: Spring Boot + * registers a {@code cborMapper} bean when Jackson's CBOR backend is on the runtime classpath, and + * Spring registers a Protobuf message converter when protobuf-java is. The sibling web leaf shipped + * the same mistake and the consequence was concrete — three {@code ObjectMapper} beans in the + * composition root, every {@code @Autowired ObjectMapper} ambiguous, and the application refusing + * to start. + * + *

Nothing else catches it. The codec's own tests pass either way, because the test source set + * has the jars. The failure appears only in whatever composes this leaf, which is a different + * build. + */ +class BinaryCodecBackendScopeTest { + + private static String buildFile() { + Path build = WebSocketBuildModel.mainSourceRoot().getParent().getParent().getParent(); + Path file = build.resolve("build.gradle"); + if (!Files.isRegularFile(file)) { + throw new IllegalStateException( + "cannot locate this leaf's build.gradle at " + file + "; the scope rule checks nothing"); + } + try { + return Files.readString(file); + } catch (IOException failure) { + throw new UncheckedIOException(failure); + } + } + + private static boolean declares(String scope, String coordinate) { + return Pattern.compile( + "^\\s*" + scope + "\\s+'" + Pattern.quote(coordinate) + "(:|')", Pattern.MULTILINE) + .matcher(buildFile()) + .find(); + } + + @Test + @DisplayName("neither backend is an implementation dependency") + void backendsAreNotOnTheRuntimeClasspath() { + for (WebSocketBinaryCodecBackend backend : WebSocketBinaryCodecBackend.values()) { + assertThat(declares("implementation", backend.coordinate())) + .as( + "%s must not be an implementation dependency: on an adopter's runtime classpath it" + + " registers a bean nobody asked for", + backend.coordinate()) + .isFalse(); + } + } + + @Test + @DisplayName("both backends are compile-only, so the codecs still compile") + void backendsAreCompileOnly() { + for (WebSocketBinaryCodecBackend backend : WebSocketBinaryCodecBackend.values()) { + assertThat(declares("compileOnly", backend.coordinate())) + .as("%s must be compileOnly", backend.coordinate()) + .isTrue(); + } + } + + @Test + @DisplayName("both backends are on the test classpath, so the codecs are actually exercised") + void backendsAreOnTheTestClasspath() { + // The other half. compileOnly alone would leave the codec tests unable to run, and a codec + // whose tests cannot run is worse than one that is absent. + for (WebSocketBinaryCodecBackend backend : WebSocketBinaryCodecBackend.values()) { + assertThat(declares("testImplementation", backend.coordinate())) + .as("%s must be on the test classpath", backend.coordinate()) + .isTrue(); + assertThat(backend.available()) + .as("%s must actually resolve in this lane", backend.coordinate()) + .isTrue(); + } + } + + @Test + @DisplayName("an absent backend would be refused by name rather than by NoClassDefFoundError") + void absentBackendIsNamed() { + // The message a deployment reads when it enables a capability and forgets the jar. Asserted on + // the text because the text is the entire value of the check. + for (WebSocketBinaryCodecBackend backend : WebSocketBinaryCodecBackend.values()) { + assertThat(backend.coordinate()).contains(":"); + } + assertThat(WebSocketBinaryCodecBackend.CBOR.coordinate()) + .isEqualTo("tools.jackson.dataformat:jackson-dataformat-cbor"); + assertThat(WebSocketBinaryCodecBackend.PROTOBUF.coordinate()) + .isEqualTo("com.google.protobuf:protobuf-java"); + } +} diff --git a/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/advanced/codec/BinaryCodecRoundTripTest.java b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/advanced/codec/BinaryCodecRoundTripTest.java new file mode 100644 index 00000000..9419ed36 --- /dev/null +++ b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/advanced/codec/BinaryCodecRoundTripTest.java @@ -0,0 +1,316 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.codec; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.google.protobuf.DescriptorProtos.DescriptorProto; +import com.google.protobuf.DescriptorProtos.FieldDescriptorProto; +import com.google.protobuf.DescriptorProtos.FileDescriptorProto; +import com.google.protobuf.Descriptors; +import com.google.protobuf.DynamicMessage; +import dev.caskeleton.adapter.inbound.websocket.advanced.codec.cbor.CborCodecProfile; +import dev.caskeleton.adapter.inbound.websocket.advanced.codec.cbor.DuplicateKeyPolicy; +import dev.caskeleton.adapter.inbound.websocket.advanced.codec.cbor.WebSocketCborCodec; +import dev.caskeleton.adapter.inbound.websocket.advanced.codec.protobuf.ProtobufCodecProfile; +import dev.caskeleton.adapter.inbound.websocket.advanced.codec.protobuf.WebSocketProtobufCodec; +import dev.caskeleton.adapter.inbound.websocket.codec.WebSocketDecodeException; +import dev.caskeleton.adapter.inbound.websocket.codec.WebSocketWireTypeManifest; +import dev.caskeleton.adapter.inbound.websocket.error.WebSocketFailureCategory; +import dev.caskeleton.adapter.inbound.websocket.protocol.WebSocketMessageCatalog; +import dev.caskeleton.adapter.inbound.websocket.protocol.WebSocketMessageDescriptor; +import dev.caskeleton.adapter.inbound.websocket.protocol.WebSocketMessageDirection; +import dev.caskeleton.adapter.inbound.websocket.protocol.WebSocketMessageFamily; +import dev.caskeleton.adapter.inbound.websocket.protocol.WebSocketMessageType; +import dev.caskeleton.adapter.inbound.websocket.protocol.WebSocketSchemaVersion; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * The two binary codecs, exercised end to end rather than asserted about. + * + *

The Protobuf side builds its descriptor at runtime from a {@code FileDescriptorProto}, which + * is how protobuf works when the schema is not known at build time. That is not a shortcut for the + * test — it is the same mechanism the codec uses in production, because a template ships no {@code + * .proto} of its own. + */ +@Tag("websocket-advanced") +class BinaryCodecRoundTripTest { + + private static final WebSocketMessageType PLACED = new WebSocketMessageType("order.placed.v1"); + private static final WebSocketMessageType UNPUBLISHED = + new WebSocketMessageType("order.secret.v1"); + + /** A wire record the CBOR codec decodes into. */ + public record OrderPlaced(String orderId, int quantity) {} + + private static WebSocketMessageCatalog catalog() { + return WebSocketMessageCatalog.of( + List.of( + new WebSocketMessageDescriptor( + PLACED, + WebSocketMessageFamily.COMMAND, + WebSocketMessageDirection.BIDIRECTIONAL, + WebSocketSchemaVersion.v(1)))); + } + + private static BinaryCodecProfile binary(int maxBytes) { + return new BinaryCodecProfile(true, WebSocketSchemaVersion.v(1), maxBytes, false); + } + + private static WebSocketCborCodec cborCodec(int maxBytes) { + return new WebSocketCborCodec( + catalog(), + WebSocketWireTypeManifest.of(Map.of(PLACED, OrderPlaced.class)), + CborCodecProfile.strict(binary(maxBytes))); + } + + // ---- CBOR ---- + + @Test + @DisplayName("a CBOR round trip returns the same record") + void cborRoundTrips() { + WebSocketCborCodec codec = cborCodec(65_536); + OrderPlaced sent = new OrderPlaced("ord-1", 3); + + byte[] encoded = codec.encodeToClient(PLACED, sent); + + assertThat(codec.decodeFromClient(PLACED, WebSocketMessageFamily.COMMAND, encoded)) + .isEqualTo(sent); + } + + @Test + @DisplayName("an unpublished type never reaches the CBOR parser") + void cborRefusesUnpublishedTypes() { + // The catalog is consulted first, so a second decode path does not widen the set of reachable + // types. + assertThatThrownBy( + () -> + cborCodec(65_536) + .decodeFromClient(UNPUBLISHED, WebSocketMessageFamily.COMMAND, new byte[] {0})) + .isInstanceOf(WebSocketDecodeException.class) + .extracting(failure -> ((WebSocketDecodeException) failure).category()) + .isEqualTo(WebSocketFailureCategory.UNKNOWN_TYPE); + } + + @Test + @DisplayName("a CBOR frame beyond the ceiling is refused before parsing") + void cborRefusesOversizedFrames() { + // Checked before the parser sees it. CBOR declares a collection's length ahead of its contents, + // so a limit applied afterwards has already paid for the allocation. + WebSocketCborCodec codec = cborCodec(8); + + assertThatThrownBy( + () -> codec.decodeFromClient(PLACED, WebSocketMessageFamily.COMMAND, new byte[64])) + .isInstanceOf(WebSocketDecodeException.class) + .extracting(failure -> ((WebSocketDecodeException) failure).category()) + .isEqualTo(WebSocketFailureCategory.TOO_LARGE); + } + + @Test + @DisplayName("a codec that would resolve duplicate keys is refused at construction") + void cborRefusesAnAmbiguousProfile() { + // Bytes with two readings, and which one a component sees depends on which decoder it uses. + assertThatThrownBy( + () -> + new WebSocketCborCodec( + catalog(), + WebSocketWireTypeManifest.of(Map.of(PLACED, OrderPlaced.class)), + new CborCodecProfile(binary(1024), true, DuplicateKeyPolicy.LAST_WINS))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("two different things to two readers"); + } + + @Test + @DisplayName("a CBOR frame with an unknown field is refused") + void cborRefusesUnknownFields() { + WebSocketCborCodec codec = cborCodec(65_536); + byte[] extra = + cborCodec(65_536) + .encodeToClient(PLACED, Map.of("orderId", "ord-1", "quantity", 3, "surprise", "x")); + + assertThatThrownBy(() -> codec.decodeFromClient(PLACED, WebSocketMessageFamily.COMMAND, extra)) + .isInstanceOf(WebSocketDecodeException.class) + .extracting(failure -> ((WebSocketDecodeException) failure).category()) + .isEqualTo(WebSocketFailureCategory.MALFORMED); + } + + // ---- Protobuf ---- + + /** + * The one descriptor instance this test uses. + * + *

Shared rather than rebuilt per call, because protobuf compares descriptors by identity and + * so does the codec. That is the right contract: a deployment compiles its schema once and builds + * every message from it, and two independently compiled copies of the same {@code .proto} are two + * different schemas as far as protobuf is concerned. + */ + private static final Descriptors.Descriptor ORDER_DESCRIPTOR = buildOrderDescriptor(); + + /** Compiles a two-field message descriptor at runtime, as a deployment's schema would. */ + private static Descriptors.Descriptor buildOrderDescriptor() { + FileDescriptorProto file = + FileDescriptorProto.newBuilder() + .setName("order.proto") + .setSyntax("proto3") + .addMessageType( + DescriptorProto.newBuilder() + .setName("OrderPlaced") + .addField( + FieldDescriptorProto.newBuilder() + .setName("order_id") + .setNumber(1) + .setType(FieldDescriptorProto.Type.TYPE_STRING) + .setLabel(FieldDescriptorProto.Label.LABEL_OPTIONAL)) + .addField( + FieldDescriptorProto.newBuilder() + .setName("quantity") + .setNumber(2) + .setType(FieldDescriptorProto.Type.TYPE_INT32) + .setLabel(FieldDescriptorProto.Label.LABEL_OPTIONAL))) + .build(); + try { + return Descriptors.FileDescriptor.buildFrom(file, new Descriptors.FileDescriptor[0]) + .findMessageTypeByName("OrderPlaced"); + } catch (Descriptors.DescriptorValidationException invalid) { + throw new IllegalStateException(invalid); + } + } + + private static WebSocketProtobufCodec protobufCodec(int maxBytes) { + return new WebSocketProtobufCodec( + catalog(), + Map.of(PLACED, ORDER_DESCRIPTOR), + new ProtobufCodecProfile("orders-descriptors:1", binary(maxBytes))); + } + + @Test + @DisplayName("a Protobuf round trip returns the same field values") + void protobufRoundTrips() { + Descriptors.Descriptor descriptor = ORDER_DESCRIPTOR; + WebSocketProtobufCodec codec = protobufCodec(65_536); + DynamicMessage sent = + DynamicMessage.newBuilder(descriptor) + .setField(descriptor.findFieldByName("order_id"), "ord-1") + .setField(descriptor.findFieldByName("quantity"), 3) + .build(); + + byte[] encoded = codec.encodeToClient(PLACED, sent); + var decoded = codec.decodeFromClient(PLACED, WebSocketMessageFamily.COMMAND, encoded); + + assertThat(decoded.getField(decoded.getDescriptorForType().findFieldByName("order_id"))) + .isEqualTo("ord-1"); + assertThat(decoded.getField(decoded.getDescriptorForType().findFieldByName("quantity"))) + .isEqualTo(3); + } + + @Test + @DisplayName("a Protobuf frame carrying an unpublished field is refused, not silently dropped") + void protobufRefusesUnknownFields() { + // Protobuf's default is to retain unknown fields so an intermediary can round-trip them, which + // at a trust boundary means re-emitting bytes this server never validated — and a field the + // server cannot see is one its authorization checks cannot consider. + Descriptors.Descriptor wider = widerDescriptor(); + DynamicMessage fromNewerPeer = + DynamicMessage.newBuilder(wider) + .setField(wider.findFieldByName("order_id"), "ord-1") + .setField(wider.findFieldByName("quantity"), 3) + .setField(wider.findFieldByName("discount_code"), "SECRET") + .build(); + + assertThatThrownBy( + () -> + protobufCodec(65_536) + .decodeFromClient( + PLACED, WebSocketMessageFamily.COMMAND, fromNewerPeer.toByteArray())) + .isInstanceOf(WebSocketDecodeException.class) + .hasMessageContaining("fields this build does not publish"); + } + + @Test + @DisplayName("a message built against another descriptor is refused on encode") + void protobufRefusesAForeignDescriptor() { + // Field numbers collide across schemas, so the peer would decode a well-formed message with + // the wrong values rather than failing to parse. + Descriptors.Descriptor wider = widerDescriptor(); + DynamicMessage foreign = + DynamicMessage.newBuilder(wider).setField(wider.findFieldByName("order_id"), "x").build(); + + assertThatThrownBy(() -> protobufCodec(65_536).encodeToClient(PLACED, foreign)) + .isInstanceOf(WebSocketDecodeException.class) + .hasMessageContaining("built against a different descriptor"); + } + + @Test + @DisplayName("an unpublished type never reaches the Protobuf parser") + void protobufRefusesUnpublishedTypes() { + assertThatThrownBy( + () -> + protobufCodec(65_536) + .decodeFromClient(UNPUBLISHED, WebSocketMessageFamily.COMMAND, new byte[] {})) + .isInstanceOf(WebSocketDecodeException.class) + .extracting(failure -> ((WebSocketDecodeException) failure).category()) + .isEqualTo(WebSocketFailureCategory.UNKNOWN_TYPE); + } + + @Test + @DisplayName("a codec with no descriptor is refused at construction") + void protobufRefusesAnEmptyDescriptorSet() { + assertThatThrownBy( + () -> + new WebSocketProtobufCodec( + catalog(), Map.of(), new ProtobufCodecProfile("none:0", binary(1024)))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("should not enable the capability"); + } + + @Test + @DisplayName("a Protobuf frame beyond the ceiling is refused before parsing") + void protobufRefusesOversizedFrames() { + assertThatThrownBy( + () -> + protobufCodec(8) + .decodeFromClient(PLACED, WebSocketMessageFamily.COMMAND, new byte[64])) + .isInstanceOf(WebSocketDecodeException.class) + .extracting(failure -> ((WebSocketDecodeException) failure).category()) + .isEqualTo(WebSocketFailureCategory.TOO_LARGE); + } + + /** The same message with one extra field, standing in for a newer peer's schema. */ + private static Descriptors.Descriptor widerDescriptor() { + FileDescriptorProto file = + FileDescriptorProto.newBuilder() + .setName("order-wider.proto") + .setSyntax("proto3") + .addMessageType( + DescriptorProto.newBuilder() + .setName("OrderPlaced") + .addField( + FieldDescriptorProto.newBuilder() + .setName("order_id") + .setNumber(1) + .setType(FieldDescriptorProto.Type.TYPE_STRING) + .setLabel(FieldDescriptorProto.Label.LABEL_OPTIONAL)) + .addField( + FieldDescriptorProto.newBuilder() + .setName("quantity") + .setNumber(2) + .setType(FieldDescriptorProto.Type.TYPE_INT32) + .setLabel(FieldDescriptorProto.Label.LABEL_OPTIONAL)) + .addField( + FieldDescriptorProto.newBuilder() + .setName("discount_code") + .setNumber(3) + .setType(FieldDescriptorProto.Type.TYPE_STRING) + .setLabel(FieldDescriptorProto.Label.LABEL_OPTIONAL))) + .build(); + try { + return Descriptors.FileDescriptor.buildFrom(file, new Descriptors.FileDescriptor[0]) + .findMessageTypeByName("OrderPlaced"); + } catch (Descriptors.DescriptorValidationException invalid) { + throw new IllegalStateException(invalid); + } + } +} diff --git a/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/advanced/codec/BinaryCodecTest.java b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/advanced/codec/BinaryCodecTest.java new file mode 100644 index 00000000..9937fc3e --- /dev/null +++ b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/advanced/codec/BinaryCodecTest.java @@ -0,0 +1,178 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.codec; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.inbound.websocket.advanced.codec.cbor.CborCodecProfile; +import dev.caskeleton.adapter.inbound.websocket.advanced.codec.cbor.DuplicateKeyPolicy; +import dev.caskeleton.adapter.inbound.websocket.advanced.codec.protobuf.DescriptorCompatibility; +import dev.caskeleton.adapter.inbound.websocket.advanced.codec.protobuf.DescriptorCompatibilityGate; +import dev.caskeleton.adapter.inbound.websocket.advanced.codec.protobuf.ProtobufCodecProfile; +import dev.caskeleton.adapter.inbound.websocket.protocol.WebSocketMessageType; +import dev.caskeleton.adapter.inbound.websocket.protocol.WebSocketSchemaVersion; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * A second codec is only safe if it means the same thing as the first. + * + *

Every case here is a way two codecs can both be individually correct and still disagree about + * what a frame said. + */ +@Tag("websocket-advanced") +class BinaryCodecTest { + + private static final WebSocketSchemaVersion V1 = WebSocketSchemaVersion.v(1); + + private static BinaryCodecProfile binary(int maxBytes) { + return new BinaryCodecProfile(true, V1, maxBytes, false); + } + + @Test + @DisplayName("a binary frame names its message type as the JSON one does") + void binaryFramesNameTheirType() { + // Without it the reader guesses from context, and a client that switched format has silently + // switched contract. + assertThatThrownBy(() -> new BinaryCodecProfile(false, V1, 1024, false)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("switched contract"); + } + + @Test + @DisplayName("a length-prefixed codec is bounded") + void lengthPrefixedCodecIsBounded() { + // The frame carries a length and the decoder allocates it, so a frame within the transport + // limit can still declare a length far beyond it. + assertThatThrownBy(() -> new BinaryCodecProfile(true, V1, 0, false)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("allocate the length it was told"); + assertThat(binary(1024).withinBounds(1024)).isTrue(); + assertThat(binary(1024).withinBounds(1025)).isFalse(); + } + + @Test + @DisplayName("a reused or retyped protobuf field number is breaking") + void retypedFieldNumberIsBreaking() { + // Nothing errors. The receiver reads a value of the right type in the wrong place, which + // surfaces as a data bug far from the change. + assertThat( + DescriptorCompatibilityGate.compare( + Map.of(1, "string", 2, "int64"), Map.of(1, "string", 2, "bytes"))) + .isEqualTo(DescriptorCompatibility.BREAKING); + + assertThatThrownBy( + () -> + DescriptorCompatibilityGate.verify( + Map.of(1, "string", 2, "int64"), Map.of(1, "string", 2, "bytes"))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("reads the wrong field without erroring"); + } + + @Test + @DisplayName("adding and removing fields are classified apart") + void addAndRemoveAreClassifiedApart() { + // They need opposite rollout orders: added fields want writers upgraded first, removed fields + // want readers upgraded first, and doing both at once has no safe order at all. + assertThat( + DescriptorCompatibilityGate.compare( + Map.of(1, "string"), Map.of(1, "string", 2, "int64"))) + .isEqualTo(DescriptorCompatibility.BACKWARD_COMPATIBLE); + assertThat( + DescriptorCompatibilityGate.compare( + Map.of(1, "string", 2, "int64"), Map.of(1, "string"))) + .isEqualTo(DescriptorCompatibility.FORWARD_COMPATIBLE); + assertThat( + DescriptorCompatibilityGate.compare( + Map.of(1, "string", 2, "int64"), Map.of(1, "string", 3, "int64"))) + .isEqualTo(DescriptorCompatibility.BREAKING); + assertThat(DescriptorCompatibilityGate.compare(Map.of(1, "string"), Map.of(1, "string"))) + .isEqualTo(DescriptorCompatibility.FULLY_COMPATIBLE); + } + + @Test + @DisplayName("a renamed field is invisible, which is why the gate reads numbers") + void renamesAreInvisibleToTheWire() { + // Field names are absent from the encoding entirely. A gate comparing names would pass exactly + // the changes that matter and fail the ones that do not. + assertThat(DescriptorCompatibilityGate.compare(Map.of(7, "string"), Map.of(7, "string"))) + .isEqualTo(DescriptorCompatibility.FULLY_COMPATIBLE); + } + + @Test + @DisplayName("the protobuf codec is not an attachment transport") + void protobufIsNotAnAttachmentTransport() { + // The broker, the replay store and every in-memory queue would each hold the payload whole. + assertThatThrownBy(() -> new ProtobufCodecProfile("orders-descriptors:3", binary(1_000_000))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("attachment transport"); + assertThat(new ProtobufCodecProfile("orders-descriptors:3", binary(65_536)).withinBounds(4096)) + .isTrue(); + } + + @Test + @DisplayName("an unnamed descriptor artifact is refused") + void unnamedDescriptorArtifactIsRefused() { + assertThatThrownBy(() -> new ProtobufCodecProfile(" ", binary(1024))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("not a version that can be compared"); + } + + @Test + @DisplayName("a CBOR map with a duplicate key is refused, not resolved") + void duplicateCborKeysAreRefused() { + // RFC 8949 leaves the resolution to the decoder. A validator reading the first value and an + // executor reading the last disagree about what the message said, and the attacker picks both. + assertThat(CborCodecProfile.strict(binary(1024)).unambiguous()).isTrue(); + assertThat(new CborCodecProfile(binary(1024), true, DuplicateKeyPolicy.LAST_WINS).unambiguous()) + .isFalse(); + } + + @Test + @DisplayName("canonical encoding is part of the profile, not a decoder default") + void canonicalEncodingIsDeclared() { + assertThat(CborCodecProfile.strict(binary(1024)).canonicalEncodingRequired()).isTrue(); + } + + @Test + @DisplayName("a codec that publishes a different catalog than JSON is refused") + void catalogParityIsEnforced() { + // Neither direction shows up in a codec's own round-trip test, which is why this compares + // catalogs rather than encodings. + WebSocketMessageType placed = new WebSocketMessageType("order.placed.v1"); + WebSocketMessageType cancelled = new WebSocketMessageType("order.cancelled.v1"); + + assertThat(SchemaParity.differences(Set.of(placed), Set.of(placed))).isEmpty(); + assertThat(SchemaParity.differences(Set.of(placed, cancelled), Set.of(placed))) + .singleElement() + .asString() + .contains("a client that switches format loses them"); + assertThat(SchemaParity.differences(Set.of(placed), Set.of(placed, cancelled))) + .singleElement() + .asString() + .contains("was not reviewed as accepting"); + assertThatThrownBy(() -> SchemaParity.verify(Set.of(placed), Set.of(cancelled))) + .isInstanceOf(IllegalStateException.class); + } + + @Test + @DisplayName("agreeing on the type but not on what it decodes to is caught") + void bindingParityIsEnforced() { + // Same types is not enough: a handler receiving a well-formed object of the wrong shape is + // the result. + WebSocketMessageType placed = new WebSocketMessageType("order.placed.v1"); + + assertThat( + SchemaParity.bindingDifferences( + Map.of(placed, String.class), Map.of(placed, Integer.class))) + .singleElement() + .asString() + .contains("decodes to java.lang.String as JSON and java.lang.Integer in binary"); + assertThat( + SchemaParity.bindingDifferences( + Map.of(placed, String.class), Map.of(placed, String.class))) + .isEmpty(); + } +} diff --git a/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/advanced/compression/CompressionPolicyTest.java b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/advanced/compression/CompressionPolicyTest.java new file mode 100644 index 00000000..79506fc9 --- /dev/null +++ b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/advanced/compression/CompressionPolicyTest.java @@ -0,0 +1,104 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.compression; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketEndpointName; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** The two ways permessage-deflate goes wrong: the length side channel, and the memory. */ +@Tag("websocket-advanced") +class CompressionPolicyTest { + + private static final WebSocketEndpointName FEED = new WebSocketEndpointName("public-feed"); + private static final WebSocketEndpointName SESSION = new WebSocketEndpointName("user-session"); + private static final WebSocketEndpointName UNKNOWN = new WebSocketEndpointName("not-declared"); + + private static CompressionPolicy policy() { + return new CompressionPolicy( + CompressionProfile.boundedMemory(15, 1024), + 1_048_576, + Map.of( + FEED, + EndpointContentClass.PUBLIC_DATA, + SESSION, + EndpointContentClass.SENSITIVE_WITH_ATTACKER_INFLUENCE)); + } + + @Test + @DisplayName("compression is off unless the profile says otherwise") + void compressionIsOffByDefault() { + assertThat(CompressionProfile.disabled().enabled()).isFalse(); + assertThat(CompressionProfile.disabled().shouldCompress(100_000)).isFalse(); + } + + @Test + @DisplayName("an endpoint mixing a secret with attacker input never compresses") + void crimeShapedEndpointNeverCompresses() { + // No parameter combination makes it safe: the leak is in the compressed length, which every + // setting produces. An attacker who injects a guess into each message recovers the secret a + // byte at a time. + assertThat(policy().mayCompress(SESSION)).isFalse(); + assertThat(policy().mayCompress(FEED)).isTrue(); + } + + @Test + @DisplayName("an endpoint nobody classified does not compress") + void unclassifiedEndpointDoesNotCompress() { + // Defaulting the other way makes every endpoint somebody forgot to classify a candidate for + // the failure above. + assertThat(policy().mayCompress(UNKNOWN)).isFalse(); + assertThat(policy().contentClassOf(UNKNOWN)).isEmpty(); + } + + @Test + @DisplayName("compression without a decompressed-size ceiling is refused") + void unboundedInflationIsRefused() { + // A 64KB frame inside every inbound bound can inflate to 64MB. The check has to be during + // inflation, not after, or the allocation has already happened. + assertThatThrownBy( + () -> new CompressionPolicy(CompressionProfile.boundedMemory(15, 0), 0, Map.of())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("hundreds of times larger"); + } + + @Test + @DisplayName("inflation stops at the ceiling") + void inflationStopsAtTheCeiling() { + CompressionPolicy policy = policy(); + + assertThat(policy.mayInflateFurther(1_048_576)).isTrue(); + assertThat(policy.mayInflateFurther(1_048_577)).isFalse(); + assertThat(policy.permittedRatioFor(1024)).isEqualTo(1024); + } + + @Test + @DisplayName("context takeover is what makes memory scale with connection lifetime") + void contextTakeoverCostsMemoryPerConnection() { + // With both windows kept alive, ten thousand connections is 640MB of sliding window before a + // single message is buffered. + assertThat(new CompressionProfile(true, false, false, 15, 0).windowBytesPerConnection()) + .isEqualTo(65_536); + assertThat(CompressionProfile.boundedMemory(15, 0).windowBytesPerConnection()).isZero(); + } + + @Test + @DisplayName("a window size outside RFC 7692 is refused") + void invalidWindowBitsAreRefused() { + assertThatThrownBy(() -> CompressionProfile.boundedMemory(16, 0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("RFC 7692"); + assertThatThrownBy(() -> CompressionProfile.boundedMemory(7, 0)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("small messages are sent uncompressed") + void smallMessagesAreNotCompressed() { + assertThat(CompressionProfile.boundedMemory(15, 1024).shouldCompress(1023)).isFalse(); + assertThat(CompressionProfile.boundedMemory(15, 1024).shouldCompress(1024)).isTrue(); + } +} diff --git a/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/advanced/graphql/GraphQlTransportBridgePolicyTest.java b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/advanced/graphql/GraphQlTransportBridgePolicyTest.java new file mode 100644 index 00000000..0cd7e7ab --- /dev/null +++ b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/advanced/graphql/GraphQlTransportBridgePolicyTest.java @@ -0,0 +1,66 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.graphql; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** A transport bridge, and the two things it is not allowed to become. */ +@Tag("websocket-advanced") +class GraphQlTransportBridgePolicyTest { + + @Test + @DisplayName("the bridge does not own GraphQL semantics") + void bridgeDoesNotOwnGraphQlSemantics() { + // graphql-transport-ws has a lifecycle that looks enough like a message protocol to + // reimplement by accident, and a second definition of what an error is disagrees with the + // first the moment a resolver throws something it did not anticipate. + assertThat(GraphQlTransportBridgePolicy.standard().ownsGraphQlSemantics()).isFalse(); + assertThatThrownBy( + () -> + new GraphQlTransportBridgePolicy( + GraphQlTransportBridgePolicy.SUBPROTOCOL, true, true)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("a resolver throws something it did not anticipate"); + } + + @Test + @DisplayName("the bridge reuses the Stable connection runtime") + void bridgeReusesTheStableRuntime() { + // Its own would have its own heartbeat, queue bound, backpressure and security checks, none of + // them the ones the platform was reviewed with. + assertThat(GraphQlTransportBridgePolicy.standard().usesStableConnectionRuntime()).isTrue(); + assertThatThrownBy( + () -> + new GraphQlTransportBridgePolicy( + GraphQlTransportBridgePolicy.SUBPROTOCOL, false, false)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("reviewed with"); + } + + @Test + @DisplayName("the subprotocol token is the one the specification defines") + void subprotocolIsTheSpecifiedToken() { + assertThat(GraphQlTransportBridgePolicy.standard().subprotocol()) + .isEqualTo("graphql-transport-ws"); + } + + @Test + @DisplayName("the GraphQL lifecycle frames are forwarded rather than interpreted") + void lifecycleFramesAreForwarded() { + assertThat(GraphQlTransportBridgePolicy.standard().forwardedWithoutInterpretation()) + .anyMatch(line -> line.contains("connection_init")) + .anyMatch(line -> line.contains("subscribe, next, complete and error")); + } + + @Test + @DisplayName("the Stable budgets are reused rather than restated") + void stableBudgetsAreReused() { + assertThat(GraphQlTransportBridgePolicy.standard().reusedFromStable()) + .anyMatch(line -> line.contains("connection budget")) + .anyMatch(line -> line.contains("shedding bound")) + .anyMatch(line -> line.contains("heartbeat")); + } +} diff --git a/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/advanced/graphql/GraphQlTransportBridgeTest.java b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/advanced/graphql/GraphQlTransportBridgeTest.java new file mode 100644 index 00000000..925f400f --- /dev/null +++ b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/advanced/graphql/GraphQlTransportBridgeTest.java @@ -0,0 +1,177 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.graphql; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketActorReference; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketConnectionContext; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketConnectionId; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketConnectionState; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketCredentialExpiry; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketEndpointName; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketNodeId; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketSessionId; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketSubprotocolName; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * The three rules the transport enforces, and the everything-else it does not. + * + *

Each of the three is about the connection rather than about a query, which is why a GraphQL + * engine cannot enforce them and this bridge must. + */ +@Tag("websocket-advanced") +class GraphQlTransportBridgeTest { + + private static final Instant OPENED = Instant.parse("2026-08-25T10:00:00Z"); + private static final Duration DEADLINE = Duration.ofSeconds(10); + + private static final byte[] SALT = + "a-deployment-salt-of-adequate-length".getBytes(StandardCharsets.UTF_8); + + private static WebSocketConnectionContext context() { + return new WebSocketConnectionContext( + new WebSocketConnectionId("c-01H8XQ2N4K"), + new WebSocketSessionId("s-01H8XQ2N4K"), + new WebSocketNodeId("edge-1"), + new WebSocketEndpointName("live-updates"), + Optional.of(WebSocketSubprotocolName.stable()), + WebSocketActorReference.of("alice", "acme", SALT), + WebSocketConnectionState.OPEN, + WebSocketCredentialExpiry.never(), + OPENED); + } + + private static GraphQlTransportBridge bridge() { + return new GraphQlTransportBridge(GraphQlTransportBridgePolicy.standard(), DEADLINE); + } + + @Test + @DisplayName("an unacknowledged connection is closed at the deadline") + void unacknowledgedConnectionTimesOut() { + // Without it an unauthenticated socket is held for as long as the peer cares to hold it, which + // is a connection slot spent by anyone who can open a TCP connection. + GraphQlTransportBridge bridge = bridge(); + + assertThat(bridge.initTimeoutAt(OPENED, OPENED.plusSeconds(9))).isEmpty(); + assertThat(bridge.initTimeoutAt(OPENED, OPENED.plusSeconds(10))) + .contains(GraphQlCloseCode.INITIALISATION_TIMEOUT); + } + + @Test + @DisplayName("an acknowledged connection never times out for initialisation") + void acknowledgedConnectionDoesNotTimeOut() { + GraphQlTransportBridge bridge = bridge(); + assertThat(bridge.onConnectionInit(OPENED)).isEmpty(); + + assertThat(bridge.initTimeoutAt(OPENED, OPENED.plusSeconds(600))).isEmpty(); + assertThat(bridge.acknowledged()).isTrue(); + } + + @Test + @DisplayName("a second connection_init is refused") + void secondInitIsRefused() { + // Not a retry: the first established the connection's parameters, and honouring a second would + // let a client change them mid-connection. + GraphQlTransportBridge bridge = bridge(); + bridge.onConnectionInit(OPENED); + + assertThat(bridge.onConnectionInit(OPENED.plusSeconds(1))) + .contains(GraphQlCloseCode.TOO_MANY_INITIALISATION_REQUESTS); + } + + @Test + @DisplayName("a subscribe before acknowledgement is unauthorized") + void subscribeBeforeInitIsUnauthorized() { + // Nothing has authenticated the connection yet, so serving it would run an operation for a peer + // nobody identified. + assertThat(bridge().onSubscribe("op-1")).contains(GraphQlCloseCode.UNAUTHORIZED); + } + + @Test + @DisplayName("two live subscriptions cannot share an operation id") + void duplicateSubscriptionIdIsRefused() { + // The id routes next and complete frames back, so sharing one interleaves two result streams + // into one with nothing to separate them. + GraphQlTransportBridge bridge = bridge(); + bridge.onConnectionInit(OPENED); + + assertThat(bridge.onSubscribe("op-1")).isEmpty(); + assertThat(bridge.onSubscribe("op-1")).contains(GraphQlCloseCode.SUBSCRIBER_ALREADY_EXISTS); + assertThat(bridge.onSubscribe("op-2")).isEmpty(); + assertThat(bridge.liveSubscriptions()).isEqualTo(2); + } + + @Test + @DisplayName("a completed id may be used again") + void completedIdIsReusable() { + // The specification permits it, and holding the id would make a client that numbers its + // operations sequentially fail on its second one. + GraphQlTransportBridge bridge = bridge(); + bridge.onConnectionInit(OPENED); + bridge.onSubscribe("op-1"); + bridge.onComplete("op-1"); + + assertThat(bridge.onSubscribe("op-1")).isEmpty(); + assertThat(bridge.liveSubscriptions()).isOne(); + } + + @Test + @DisplayName("a frame is forwarded byte for byte") + void framesAreForwardedUntouched() { + // Parsing here to "validate" would be the start of a second GraphQL implementation, and it + // would disagree with the engine's the first time a resolver throws something unanticipated. + AtomicInteger forwarded = new AtomicInteger(); + StringBuilder seen = new StringBuilder(); + bridge() + .forward( + context(), + "{\"type\":\"subscribe\",\"payload\":{\"query\":\"{ x }\"}}", + (context, payload) -> { + forwarded.incrementAndGet(); + seen.append(payload); + }); + + assertThat(forwarded.get()).isOne(); + assertThat(seen).hasToString("{\"type\":\"subscribe\",\"payload\":{\"query\":\"{ x }\"}}"); + } + + @Test + @DisplayName("a bridge without an init deadline is refused") + void deadlelessBridgeIsRefused() { + assertThatThrownBy( + () -> + new GraphQlTransportBridge(GraphQlTransportBridgePolicy.standard(), Duration.ZERO)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("anyone who can connect"); + } + + @Test + @DisplayName("the close reasons are fixed text, never the client's own input") + void closeReasonsAreFixed() { + // A close reason is echoed straight back over the wire, so reflecting client input makes a + // close frame an injection point into whatever reads the peer's logs. + for (GraphQlCloseCode code : GraphQlCloseCode.values()) { + assertThat(code.reason()).isNotBlank(); + assertThat(code.code()).isBetween(4000, 4999); + } + assertThat(GraphQlCloseCode.SUBSCRIBER_ALREADY_EXISTS.reason()) + .doesNotContain("op-") + .isEqualTo("subscriber already exists"); + } + + @Test + @DisplayName("the bridge states what it enforces and what it forwards") + void responsibilitiesAreStated() { + assertThat(bridge().enforcedByTheTransport()).hasSize(3); + assertThat(bridge().policy().ownsGraphQlSemantics()).isFalse(); + assertThat(bridge().policy().usesStableConnectionRuntime()).isTrue(); + } +} diff --git a/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/advanced/http2/HttpCompatibilityTest.java b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/advanced/http2/HttpCompatibilityTest.java new file mode 100644 index 00000000..36415ed7 --- /dev/null +++ b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/advanced/http2/HttpCompatibilityTest.java @@ -0,0 +1,91 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.http2; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.inbound.websocket.advanced.http3.Http3ExperimentalProfile; +import java.util.Set; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * The gap between a standard existing and a path supporting it. + * + *

RFC 8441 has been published since 2018 and a request still dies if any single hop does not + * implement extended CONNECT — and it dies, rather than negotiating a fallback. + */ +@Tag("websocket-advanced") +class HttpCompatibilityTest { + + @Test + @DisplayName("extended CONNECT cannot be enabled without a validated client and proxy") + void enablingWithoutEvidenceIsRefused() { + assertThatThrownBy(() -> new Http2CompatibilityProfile(true, Set.of("chrome"), Set.of())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("rather than negotiating a fallback"); + assertThat(Http2CompatibilityProfile.disabled().enabled()).isFalse(); + } + + @Test + @DisplayName("an untested hop is refused, not assumed") + void untestedHopIsRefused() { + // The assumption is the whole failure: an untested intermediary is exactly the one that will + // not implement it. + Http2CompatibilityProfile profile = + new Http2CompatibilityProfile(true, Set.of("chrome"), Set.of("nginx-1.27")); + + assertThat(profile.supports("chrome", "nginx-1.27")).isTrue(); + assertThat(profile.supports("chrome", "corporate-proxy")).isFalse(); + assertThat(profile.supports("curl", "nginx-1.27")).isFalse(); + } + + @Test + @DisplayName("the classic upgrade path stays tested while extended CONNECT is on") + void classicUpgradeStaysTested() { + // It is what every unlisted client uses, and what the listed ones fall back to when a proxy is + // swapped. + assertThat(Http2CompatibilityProfile.disabled().fallbackPath()) + .anyMatch(line -> line.contains("Upgrade: websocket")) + .anyMatch(line -> line.contains("must keep passing its own contract test")); + } + + @Test + @DisplayName("HTTP/3 is never advertised as stable support") + void http3IsNeverStable() { + assertThat(new Http3ExperimentalProfile(true, "netty-quic", Set.of("chrome")).stableSupport()) + .isFalse(); + } + + @Test + @DisplayName("HTTP/3 promotion requires a rollback that does not need a deploy") + void http3PromotionRequiresRollback() { + // The population that cannot connect over QUIC is also the population that cannot be surveyed, + // so the way back has to work without shipping a build. + Http3ExperimentalProfile profile = + new Http3ExperimentalProfile(true, "netty-quic", Set.of("chrome")); + + assertThat(profile.promotable(true, true)).isTrue(); + assertThat(profile.promotable(false, true)).isFalse(); + assertThat(profile.promotable(true, false)).isFalse(); + assertThat(new Http3ExperimentalProfile(true, "netty-quic", Set.of()).promotable(true, true)) + .isFalse(); + } + + @Test + @DisplayName("the QUIC implementation is named because they differ") + void quicImplementationIsNamed() { + assertThatThrownBy(() -> new Http3ExperimentalProfile(true, " ", Set.of("chrome"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("does not say which behaviour"); + } + + @Test + @DisplayName("the matrix records the dimensions a single pass/fail would hide") + void matrixDimensionsAreRecorded() { + assertThat(Http3ExperimentalProfile.disabled().requiredMatrixDimensions()) + .anyMatch(line -> line.contains("UDP")) + .anyMatch(line -> line.contains("packet loss")) + .anyMatch(line -> line.contains("proxy")); + } +} diff --git a/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/advanced/presence/PresenceSummaryTest.java b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/advanced/presence/PresenceSummaryTest.java new file mode 100644 index 00000000..4dd98e2c --- /dev/null +++ b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/advanced/presence/PresenceSummaryTest.java @@ -0,0 +1,113 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.presence; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketActorReference; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * Presence, and the reason nothing important may depend on it. + * + *

It is derived from a cluster index that caches facts owned by other machines, so it is always + * a statement about the past. An attacker who can make a node stop reporting can therefore move the + * platform's belief about who is present — which is fine for a green dot and not for anything else. + */ +@Tag("websocket-advanced") +class PresenceSummaryTest { + + private static final Instant NOW = Instant.parse("2026-08-25T10:00:00Z"); + private static final Duration IDLE_AFTER = Duration.ofSeconds(30); + private static final Duration STALE_AFTER = Duration.ofSeconds(90); + private static final byte[] SALT = + "a-deployment-salt-of-adequate-length".getBytes(StandardCharsets.UTF_8); + private static final WebSocketActorReference ALICE = + WebSocketActorReference.of("alice", "acme", SALT); + + private static PresenceSummary at(int connections, long secondsLate) { + return PresenceSummary.classify( + ALICE, connections, NOW, IDLE_AFTER, STALE_AFTER, NOW.plusSeconds(secondsLate)); + } + + @Test + @DisplayName("recent connections are online, quiet ones are idle") + void recentConnectionsAreOnline() { + assertThat(at(2, 5).state()).isEqualTo(PresenceState.ONLINE); + assertThat(at(2, 45).state()).isEqualTo(PresenceState.IDLE); + assertThat(at(2, 45).appearsOnline()).isTrue(); + } + + @Test + @DisplayName("an observation nobody refreshed is stale, not offline") + void unrefreshedObservationIsStale() { + // The distinction that matters during an incident: a Redis partition makes every entry stop + // being refreshed. Reporting that as OFFLINE tells the operator every user disconnected, when + // what actually happened is that the index went dark and the connections are fine. + PresenceSummary summary = at(3, 120); + + assertThat(summary.state()).isEqualTo(PresenceState.STALE); + assertThat(summary.fresh()).isFalse(); + assertThat(summary.appearsOnline()).isFalse(); + } + + @Test + @DisplayName("staleness is judged before the connection count is believed") + void stalenessOutranksTheCount() { + // Reading the count first is precisely how a dead node keeps a green dot lit. + assertThat(at(9, 120).state()).isEqualTo(PresenceState.STALE); + } + + @Test + @DisplayName("a fresh observation of nothing is offline") + void freshEmptyObservationIsOffline() { + PresenceSummary summary = at(0, 5); + + assertThat(summary.state()).isEqualTo(PresenceState.OFFLINE); + assertThat(summary.fresh()).isTrue(); + assertThat(summary.appearsOnline()).isFalse(); + assertThat(PresenceSummary.absent(ALICE, NOW).state()).isEqualTo(PresenceState.OFFLINE); + } + + @Test + @DisplayName("presence keeps the count and the observation time together") + void countAndObservationTimeTravelTogether() { + // Either alone is misleading: a count without a time reads as current, and a time without a + // count cannot answer the question that was asked. + PresenceSummary summary = at(2, 5); + + assertThat(summary.activeConnectionCount()).isEqualTo(2); + assertThat(summary.lastObservedAt()).isEqualTo(NOW); + } + + @Test + @DisplayName("presence names an actor by fingerprint") + void presenceCarriesNoRawIdentity() { + assertThat(at(1, 1).actor().fingerprint()).doesNotContain("alice"); + } + + @Test + @DisplayName("offline with live connections is refused") + void offlineWithConnectionsIsRefused() { + assertThatThrownBy(() -> new PresenceSummary(ALICE, 2, PresenceState.OFFLINE, NOW)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("contradiction"); + } + + @Test + @DisplayName("an idle window at or past the stale window is refused") + void unreachableIdleWindowIsRefused() { + // Otherwise IDLE is unreachable and the caller believes it has a four-state model when it has + // three. + assertThatThrownBy( + () -> + PresenceSummary.classify( + ALICE, 1, NOW, Duration.ofSeconds(90), Duration.ofSeconds(90), NOW)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("silently unreachable"); + } +} diff --git a/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/advanced/release/AdvancedPromotionGateTest.java b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/advanced/release/AdvancedPromotionGateTest.java new file mode 100644 index 00000000..a67abf02 --- /dev/null +++ b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/advanced/release/AdvancedPromotionGateTest.java @@ -0,0 +1,118 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.release; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.inbound.websocket.advanced.WebSocketAdvancedCapability; +import java.time.Duration; +import java.util.Set; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** What a capability has to show before it is allowed near production. */ +@Tag("websocket-advanced") +class AdvancedPromotionGateTest { + + private static AdvancedPromotionGate satisfied() { + return new AdvancedPromotionGate(Set.of("websocket:test"), Duration.ofHours(8), true, true); + } + + @Test + @DisplayName("capabilities are gated one at a time, with the suites their own failures need") + void capabilitiesAreGatedIndividually() { + // They share a feature-flag mechanism and nothing else. Promoting them together means the + // evidence for the cheapest is treated as evidence for the most dangerous. + assertThat( + AdvancedPromotionGate.forCapability(WebSocketAdvancedCapability.CLUSTER_REDIS) + .requiredSuites()) + .contains("multi-node-fanout", "node-loss"); + assertThat( + AdvancedPromotionGate.forCapability(WebSocketAdvancedCapability.BROKER_RELAY_RABBIT) + .requiredSuites()) + .contains("broker-outage", "broker-reconnect"); + assertThat( + AdvancedPromotionGate.forCapability(WebSocketAdvancedCapability.COMPRESSION) + .requiredSuites()) + .contains("decompression-bound", "memory-under-load"); + } + + @Test + @DisplayName("soak length scales with how long the failure takes to appear") + void soakScalesWithTheFailureMode() { + assertThat( + AdvancedPromotionGate.forCapability(WebSocketAdvancedCapability.HTTP3_EXPERIMENTAL) + .minimumSoak()) + .isEqualTo(Duration.ofDays(7)); + assertThat( + AdvancedPromotionGate.forCapability(WebSocketAdvancedCapability.CLUSTER_MESSAGING) + .minimumSoak()) + .isEqualTo(Duration.ofHours(24)); + assertThat( + AdvancedPromotionGate.forCapability(WebSocketAdvancedCapability.RESUME).minimumSoak()) + .isEqualTo(Duration.ofHours(8)); + } + + @Test + @DisplayName("a changed Stable contract blocks promotion") + void changedStableContractBlocksPromotion() { + // If enabling the capability changed Stable's wire contract or dependency graph, Stable was + // never independent of it, and deployments that did not enable it are affected anyway. + AdvancedPromotionGate gate = + new AdvancedPromotionGate(Set.of("websocket:test"), Duration.ofHours(8), true, false); + + assertThat(gate.blockers(Set.of("websocket:test"), Duration.ofHours(9))) + .singleElement() + .asString() + .contains("not independent of this capability"); + } + + @Test + @DisplayName("an unexercised rollback blocks promotion") + void unexercisedRollbackBlocksPromotion() { + // A flag nobody has turned off is not known to turn off, and the moment it is needed is the + // worst time to find out. + AdvancedPromotionGate gate = + new AdvancedPromotionGate(Set.of("websocket:test"), Duration.ofHours(8), false, true); + + assertThat(gate.blockers(Set.of("websocket:test"), Duration.ofHours(9))) + .singleElement() + .asString() + .contains("worst time to find out"); + } + + @Test + @DisplayName("blockers are listed rather than collapsed to a boolean") + void blockersAreEnumerated() { + // "Not yet" without a list is a gate somebody works around instead of satisfying. + AdvancedPromotionGate gate = + new AdvancedPromotionGate( + Set.of("websocket:test", "broker-outage"), Duration.ofHours(8), false, false); + + assertThat(gate.blockers(Set.of("websocket:test"), Duration.ofHours(1))) + .hasSize(4) + .anyMatch(line -> line.contains("broker-outage")) + .anyMatch(line -> line.contains("short of the required")) + .anyMatch(line -> line.contains("rollback not exercised")) + .anyMatch(line -> line.contains("dependency graph changed")); + } + + @Test + @DisplayName("a fully satisfied gate promotes") + void satisfiedGatePromotes() { + assertThat(satisfied().promotable(Set.of("websocket:test"), Duration.ofHours(8))).isTrue(); + assertThat(satisfied().promotable(Set.of("websocket:test"), Duration.ofHours(7))).isFalse(); + } + + @Test + @DisplayName("a gate requiring nothing is refused") + void emptyGateIsRefused() { + // It passes everything, which is worse than no gate because it reads as one. + assertThatThrownBy(() -> new AdvancedPromotionGate(Set.of(), Duration.ofHours(1), true, true)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("reads as one"); + assertThatThrownBy(() -> new AdvancedPromotionGate(Set.of("a"), Duration.ZERO, true, true)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("hours of real traffic"); + } +} diff --git a/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/advanced/resume/ResumeDecisionTest.java b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/advanced/resume/ResumeDecisionTest.java new file mode 100644 index 00000000..9fc5d363 --- /dev/null +++ b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/advanced/resume/ResumeDecisionTest.java @@ -0,0 +1,189 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.resume; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketActorReference; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketEndpointName; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketSessionId; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketSubprotocolName; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * What a reconnecting client is given, and the ways that decision goes quietly wrong. + * + *

Resume is the one moment a long-lived session's permissions are re-examined. The point of a + * session surviving a reconnect is that it does not re-authenticate — so a resume path that does + * not check authority is a path on which a revoked permission comes back with the client and stays + * until it stops reconnecting. + */ +@Tag("websocket-advanced") +class ResumeDecisionTest { + + private static final Instant NOW = Instant.parse("2026-08-25T10:00:00Z"); + private static final byte[] SALT = + "a-deployment-salt-of-adequate-length".getBytes(StandardCharsets.UTF_8); + private static final WebSocketSessionId SESSION = new WebSocketSessionId("s-01H8XQ2N4K"); + private static final WebSocketEndpointName ENDPOINT = new WebSocketEndpointName("live-updates"); + private static final WebSocketSubprotocolName PROTOCOL = WebSocketSubprotocolName.stable(); + private static final WebSocketActorReference ALICE = + WebSocketActorReference.of("alice", "acme", SALT); + + private static ResumeTokenPayload token(Map positions) { + return new ResumeTokenPayload( + SESSION, ALICE, ENDPOINT, PROTOCOL, positions, NOW, NOW.plusSeconds(60)); + } + + private ResumeDecision decide( + ResumeCoordinator coordinator, Set held, Map positions) { + return coordinator.decide( + token(positions), + ALICE, + ENDPOINT, + PROTOCOL, + held, + Set.of("feed:read"), + Map.of("orders", 100L), + NOW.plusSeconds(1)); + } + + @Test + @DisplayName("an available range is replayed from the next position") + void availableRangeIsReplayed() { + ResumeCoordinator coordinator = + new ResumeCoordinator(ReplayAvailability.of(Map.of("orders", 1L))); + + ResumeDecision decision = decide(coordinator, Set.of("feed:read"), Map.of("orders", 42L)); + + assertThat(decision.outcome()).isEqualTo(ResumeDecision.Outcome.REPLAY); + // From 43, not 42: the client said it saw 42, so replaying 42 would deliver it twice. + assertThat(decision.replayFrom().orElseThrow()).containsEntry("orders", 43L); + assertThat(decision.clientKeepsItsState()).isTrue(); + } + + @Test + @DisplayName("an aged-out range gets a snapshot with a new baseline") + void agedOutRangeGetsASnapshot() { + // Its own outcome because the client must discard what it had. Told this is a replay, it would + // merge current state into stale history and believe the result is continuous. + ResumeCoordinator coordinator = + new ResumeCoordinator(ReplayAvailability.of(Map.of("orders", 90L))); + + ResumeDecision decision = decide(coordinator, Set.of("feed:read"), Map.of("orders", 42L)); + + assertThat(decision.outcome()).isEqualTo(ResumeDecision.Outcome.SNAPSHOT); + assertThat(decision.snapshotBaseline().orElseThrow()).containsEntry("orders", 100L); + assertThat(decision.clientKeepsItsState()).isFalse(); + } + + @Test + @DisplayName("a revoked authority refuses the resume") + void revokedAuthorityRefusesResume() { + // The check that exists because a resume does not re-authenticate. Without it the permission + // comes back with the client every time it reconnects. + ResumeCoordinator coordinator = + new ResumeCoordinator(ReplayAvailability.of(Map.of("orders", 1L))); + + ResumeDecision decision = decide(coordinator, Set.of(), Map.of("orders", 42L)); + + assertThat(decision.outcome()).isEqualTo(ResumeDecision.Outcome.REFUSED); + assertThat(decision.reason()).contains("no longer holds"); + } + + @Test + @DisplayName("authorisation is checked before availability") + void authorisationPrecedesAvailability() { + // Two reasons. A replay-store lookup should not be spent on a caller who may not have the + // answer, and "your history has aged out" confirms the session exists. + ResumeCoordinator coordinator = + new ResumeCoordinator( + streamId -> { + throw new AssertionError("availability was consulted before authorisation"); + }); + + assertThat(decide(coordinator, Set.of(), Map.of("orders", 42L)).outcome()) + .isEqualTo(ResumeDecision.Outcome.REFUSED); + } + + @Test + @DisplayName("a token for another actor is refused") + void tokenForAnotherActorIsRefused() { + ResumeCoordinator coordinator = + new ResumeCoordinator(ReplayAvailability.of(Map.of("orders", 1L))); + + ResumeDecision decision = + coordinator.decide( + token(Map.of("orders", 42L)), + WebSocketActorReference.of("mallory", "acme", SALT), + ENDPOINT, + PROTOCOL, + Set.of("feed:read"), + Set.of("feed:read"), + Map.of("orders", 100L), + NOW.plusSeconds(1)); + + assertThat(decision.outcome()).isEqualTo(ResumeDecision.Outcome.REFUSED); + } + + @Test + @DisplayName("one aged-out stream snapshots all of them") + void oneAgedOutStreamSnapshotsEverything() { + // Replaying the available streams and snapshotting the rest hands the client a mixture it has + // no way to reason about — part continuous, part a new baseline, nothing saying which. + ResumeCoordinator coordinator = + new ResumeCoordinator(ReplayAvailability.of(Map.of("orders", 1L, "prices", 900L))); + + ResumeDecision decision = + coordinator.decide( + token(Map.of("orders", 42L, "prices", 10L)), + ALICE, + ENDPOINT, + PROTOCOL, + Set.of("feed:read"), + Set.of("feed:read"), + Map.of("orders", 100L, "prices", 950L), + NOW.plusSeconds(1)); + + assertThat(decision.outcome()).isEqualTo(ResumeDecision.Outcome.SNAPSHOT); + } + + @Test + @DisplayName("an unknown stream is not the same as an aged-out one") + void unknownStreamIsDistinctFromAgedOut() { + // Answering 0 for both would make a resume for a stream that no longer exists look like a + // complete replay of nothing. + ResumeCoordinator coordinator = new ResumeCoordinator(ReplayAvailability.none()); + + ResumeDecision decision = decide(coordinator, Set.of("feed:read"), Map.of("orders", 42L)); + + assertThat(decision.outcome()).isEqualTo(ResumeDecision.Outcome.SNAPSHOT); + assertThat(decision.reason()).contains("not replayable"); + } + + @Test + @DisplayName("a snapshot without a baseline is refused at construction") + void snapshotNeedsABaseline() { + // Without one the client has current state and no idea what sequence follows it, so the next + // message reads as a gap. + assertThatThrownBy( + () -> + new ResumeDecision( + ResumeDecision.Outcome.SNAPSHOT, Optional.empty(), Optional.empty(), "why")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("reads as a gap"); + } + + @Test + @DisplayName("resume is off unless the capability is enabled") + void resumeRequiresItsCapability() { + assertThat(ResumeCoordinator.resumable(SESSION, false)).isFalse(); + assertThat(ResumeCoordinator.resumable(SESSION, true)).isTrue(); + } +} diff --git a/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/advanced/resume/ResumeTokenTest.java b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/advanced/resume/ResumeTokenTest.java new file mode 100644 index 00000000..179e1368 --- /dev/null +++ b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/advanced/resume/ResumeTokenTest.java @@ -0,0 +1,206 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.resume; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.inbound.websocket.advanced.WebSocketAdvancedCapability; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketActorReference; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketEndpointName; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketSessionId; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketSubprotocolName; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.util.Map; +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * A credential that authorises reading somebody's session history. + * + *

That is what a resume token is, and it explains the shape of every rule here. It is not a + * convenience identifier: presented successfully it produces the messages a session missed, so a + * forgeable, unbound or replayable one hands over another caller's data. + */ +@Tag("websocket-advanced") +class ResumeTokenTest { + + private static final Instant NOW = Instant.parse("2026-08-25T10:00:00Z"); + private static final byte[] SALT = + "a-deployment-salt-of-adequate-length".getBytes(StandardCharsets.UTF_8); + private static final WebSocketSessionId SESSION = new WebSocketSessionId("s-01H8XQ2N4K"); + private static final WebSocketEndpointName ENDPOINT = new WebSocketEndpointName("live-updates"); + private static final WebSocketSubprotocolName PROTOCOL = WebSocketSubprotocolName.stable(); + private static final WebSocketActorReference ALICE = + WebSocketActorReference.of("alice", "acme", SALT); + + private static SecretKey key(String seed) { + return new SecretKeySpec( + (seed + "0".repeat(40)).substring(0, 40).getBytes(StandardCharsets.UTF_8), "HmacSHA256"); + } + + private final ResumeTokenKeyRing ring = + ResumeTokenKeyRing.of("k1", Map.of("k1", key("current"), "k0", key("retired"))); + private final ResumeTokenCodec codec = new ResumeTokenCodec(ring); + + private static ResumeTokenPayload payload() { + return new ResumeTokenPayload( + SESSION, ALICE, ENDPOINT, PROTOCOL, Map.of("orders", 42L), NOW, NOW.plusSeconds(60)); + } + + @Test + @DisplayName("a freshly minted token verifies and round-trips its positions") + void freshTokenVerifies() { + ResumeTokenCodec.Verification verification = + codec.decode(codec.encode(payload()), NOW.plusSeconds(1)); + + assertThat(verification.outcome()).isEqualTo(ResumeTokenOutcome.ACCEPTED); + assertThat(verification.payload().orElseThrow().streamPositions()).containsEntry("orders", 42L); + } + + @Test + @DisplayName("a tampered payload is refused") + void tamperedTokenIsRefused() { + String token = codec.encode(payload()); + String tampered = "A" + token.substring(1); + + assertThat(codec.decode(tampered, NOW.plusSeconds(1)).outcome()) + .isIn(ResumeTokenOutcome.TAMPERED, ResumeTokenOutcome.UNSUPPORTED_VERSION); + } + + @Test + @DisplayName("a token signed by a key this deployment does not have is UNKNOWN_KEY") + void unknownKeyIsDistinguished() { + // Usually a rotation that retired a key still in circulation, occasionally a forgery. The two + // are indistinguishable from the token, and an operator needs to know which spike this is. + ResumeTokenCodec foreign = + new ResumeTokenCodec(ResumeTokenKeyRing.of("k9", Map.of("k9", key("elsewhere")))); + + assertThat(codec.decode(foreign.encode(payload()), NOW.plusSeconds(1)).outcome()) + .isEqualTo(ResumeTokenOutcome.UNKNOWN_KEY); + } + + @Test + @DisplayName("a retired key still verifies, so rotation is not an outage") + void retiredKeyStillVerifies() { + // Replacing the key with a single new one invalidates every token in flight and tells every + // client to start fresh at the same moment — the exact failure resume exists to prevent. + ResumeTokenCodec oldCodec = + new ResumeTokenCodec(ResumeTokenKeyRing.of("k0", Map.of("k0", key("retired")))); + String mintedBeforeRotation = oldCodec.encode(payload()); + + assertThat(codec.decode(mintedBeforeRotation, NOW.plusSeconds(1)).outcome()) + .isEqualTo(ResumeTokenOutcome.ACCEPTED); + } + + @Test + @DisplayName("an expired token is EXPIRED, not TAMPERED") + void expiredTokenIsDistinguished() { + assertThat(codec.decode(codec.encode(payload()), NOW.plusSeconds(120)).outcome()) + .isEqualTo(ResumeTokenOutcome.EXPIRED); + } + + @Test + @DisplayName("expiry is checked after the signature, never before") + void expiryIsCheckedAfterTheSignature() { + // An unverified payload's expiry is a number the client chose. Reading it first would let a + // forged token with a distant expiry get further than one with a near one. + ResumeTokenCodec foreign = + new ResumeTokenCodec(ResumeTokenKeyRing.of("k9", Map.of("k9", key("elsewhere")))); + ResumeTokenPayload longLived = + new ResumeTokenPayload( + SESSION, ALICE, ENDPOINT, PROTOCOL, Map.of(), NOW, NOW.plus(Duration.ofDays(1))); + + // Signed by an unknown key: rejected for that, not accepted because it had not expired. + assertThat(codec.decode(foreign.encode(longLived), NOW.plusSeconds(1)).outcome()) + .isEqualTo(ResumeTokenOutcome.UNKNOWN_KEY); + } + + @Test + @DisplayName("a token authorises only its own actor, endpoint, session and protocol") + void tokenIsBoundToAllFour() { + // Each binding closes a specific hole, and checking three of four reads as thorough. + ResumeTokenPayload token = payload(); + + assertThat(token.authorises(SESSION, ALICE, ENDPOINT, PROTOCOL)).isTrue(); + // Another actor: without this the token is a bearer credential. + assertThat( + token.authorises( + SESSION, WebSocketActorReference.of("mallory", "acme", SALT), ENDPOINT, PROTOCOL)) + .isFalse(); + // Another endpoint: without this a low-privilege token resumes a privileged feed. + assertThat(token.authorises(SESSION, ALICE, new WebSocketEndpointName("admin-feed"), PROTOCOL)) + .isFalse(); + // Another session: without this there is nothing being resumed. + assertThat(token.authorises(new WebSocketSessionId("s-other0001"), ALICE, ENDPOINT, PROTOCOL)) + .isFalse(); + // Another subprotocol: without this the client resumes into a format it no longer speaks and + // every subsequent frame fails to decode. + assertThat( + token.authorises( + SESSION, ALICE, ENDPOINT, new WebSocketSubprotocolName("vendor.other.v1.json"))) + .isFalse(); + } + + @Test + @DisplayName("a key shorter than 256 bits is refused") + void shortKeyIsRefused() { + assertThatThrownBy( + () -> + ResumeTokenKeyRing.of( + "k1", + Map.of( + "k1", + new SecretKeySpec("short".getBytes(StandardCharsets.UTF_8), "HmacSHA256")))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("somebody else's messages"); + } + + @Test + @DisplayName("a ring whose current key is not in it is refused") + void currentKeyMustBeInTheRing() { + assertThatThrownBy(() -> ResumeTokenKeyRing.of("k9", Map.of("k1", key("current")))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("nothing it signs verifies"); + } + + @Test + @DisplayName("only the recoverable outcomes tell the client to retry") + void onlyRecoverableOutcomesInviteARetry() { + // Telling a client which of the failures it hit would tell an attacker the same. + assertThat(ResumeTokenOutcome.EXPIRED.clientMayRetryWithNewToken()).isTrue(); + assertThat(ResumeTokenOutcome.UNKNOWN_KEY.clientMayRetryWithNewToken()).isTrue(); + assertThat(ResumeTokenOutcome.TAMPERED.clientMayRetryWithNewToken()).isFalse(); + assertThat(ResumeTokenOutcome.REPLAYED.clientMayRetryWithNewToken()).isFalse(); + assertThat(ResumeTokenOutcome.NOT_AUTHORISED.clientMayRetryWithNewToken()).isFalse(); + } + + @Test + @DisplayName("every advanced capability has its own flag and none defaults on") + void everyCapabilityHasItsOwnFlag() { + // One flag for "advanced" would make enabling resume and enabling a network dependency in the + // delivery path the same decision. + assertThat(WebSocketAdvancedCapability.values()).hasSizeGreaterThan(10); + assertThat(WebSocketAdvancedCapability.RESUME.propertyName()) + .isEqualTo("backend.websocket.advanced.resume.enabled"); + assertThat( + java.util.Arrays.stream(WebSocketAdvancedCapability.values()) + .map(WebSocketAdvancedCapability::propertyName) + .distinct() + .count()) + .isEqualTo(WebSocketAdvancedCapability.values().length); + } + + @Test + @DisplayName("capabilities that change failure modes are marked as such") + void failureModeChangingCapabilitiesAreMarked() { + // Turning on a network dependency in the delivery path is not the same kind of decision as + // turning on a codec. + assertThat(WebSocketAdvancedCapability.CLUSTER_REDIS.altersFailureModes()).isTrue(); + assertThat(WebSocketAdvancedCapability.RESUME.altersFailureModes()).isTrue(); + assertThat(WebSocketAdvancedCapability.CODEC_CBOR.altersFailureModes()).isFalse(); + } +} diff --git a/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/advanced/sockjs/SockJsCompatibilityProfileTest.java b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/advanced/sockjs/SockJsCompatibilityProfileTest.java new file mode 100644 index 00000000..d240d28a --- /dev/null +++ b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/advanced/sockjs/SockJsCompatibilityProfileTest.java @@ -0,0 +1,109 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.sockjs; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.inbound.websocket.advanced.compression.EndpointContentClass; +import java.time.Duration; +import java.util.Set; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * The fallback transports, and what they put back that a raw WebSocket had removed. + * + *

They are HTTP requests, and HTTP requests carry cookies — which puts CSRF back on a surface + * that did not have it. + */ +@Tag("websocket-advanced") +class SockJsCompatibilityProfileTest { + + @Test + @DisplayName("SockJS is off for a new service") + void sockJsIsOffByDefault() { + assertThat(SockJsCompatibilityProfile.disabled().enabled()).isFalse(); + assertThat(SockJsCompatibilityProfile.disabled().mayServe(EndpointContentClass.PUBLIC_DATA)) + .isFalse(); + } + + @Test + @DisplayName("JSONP polling may not carry sensitive content") + void jsonpMayNotCarrySensitiveContent() { + // JSONP executes server-supplied script in the page, so a sensitive payload becomes script the + // page runs, readable by anything else running there. + SockJsCompatibilityProfile withJsonp = + new SockJsCompatibilityProfile( + true, + Set.of(SockJsTransport.XHR_POLLING, SockJsTransport.JSONP_POLLING), + Duration.ofHours(1)); + + assertThat(withJsonp.mayServe(EndpointContentClass.PUBLIC_DATA)).isTrue(); + assertThat(withJsonp.mayServe(EndpointContentClass.SENSITIVE)).isFalse(); + } + + @Test + @DisplayName("without JSONP a sensitive endpoint may still use the fallbacks") + void nonScriptFallbacksMayCarrySensitiveContent() { + SockJsCompatibilityProfile safe = + new SockJsCompatibilityProfile( + true, + Set.of(SockJsTransport.XHR_POLLING, SockJsTransport.XHR_STREAMING), + Duration.ofHours(1)); + + assertThat(safe.mayServe(EndpointContentClass.SENSITIVE)).isTrue(); + } + + @Test + @DisplayName("the security checks are not relaxed relative to a raw WebSocket") + void securityChecksAreNotRelaxed() { + assertThat(SockJsCompatibilityProfile.disabled().inheritedFromStable()) + .anyMatch(line -> line.startsWith("origin:")) + .anyMatch(line -> line.startsWith("CSRF:")) + .anyMatch(line -> line.startsWith("connection budget:")) + .anyMatch(line -> line.startsWith("authentication:")); + } + + @Test + @DisplayName("the session cookie expires, and not in a week") + void sessionCookieIsBounded() { + // A session-lifetime cookie on a fallback transport outlives the connection it was issued for, + // and is a bearer credential the whole time. + assertThatThrownBy( + () -> + new SockJsCompatibilityProfile( + true, Set.of(SockJsTransport.XHR_POLLING), Duration.ZERO)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("must expire"); + assertThatThrownBy( + () -> + new SockJsCompatibilityProfile( + true, Set.of(SockJsTransport.XHR_POLLING), Duration.ofDays(7))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("bearer credential"); + } + + @Test + @DisplayName("enabling SockJS with no transport is refused") + void emptyTransportSetIsRefused() { + assertThatThrownBy(() -> new SockJsCompatibilityProfile(true, Set.of(), Duration.ofHours(1))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("still exposes the endpoint"); + } + + @Test + @DisplayName("the transports a proxy response timeout will cut are identified") + void proxyTimeoutExposureIsIdentified() { + SockJsCompatibilityProfile profile = + new SockJsCompatibilityProfile( + true, + Set.of( + SockJsTransport.XHR_POLLING, + SockJsTransport.XHR_STREAMING, + SockJsTransport.EVENT_SOURCE), + Duration.ofHours(1)); + + assertThat(profile.transportsExposedToProxyTimeouts()) + .containsExactly(SockJsTransport.XHR_STREAMING, SockJsTransport.EVENT_SOURCE); + } +} diff --git a/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/SimpleBrokerProfileTest.java b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/SimpleBrokerProfileTest.java new file mode 100644 index 00000000..ff4c323a --- /dev/null +++ b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/SimpleBrokerProfileTest.java @@ -0,0 +1,62 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.stomp; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * What the in-memory broker cannot do. + * + *

It is the default, works perfectly in a single-node test, and fails in production in ways that + * look like application bugs. + */ +@Tag("websocket-advanced") +class SimpleBrokerProfileTest { + + @Test + @DisplayName("neither clustering nor durable acknowledgement is claimed") + void neitherClusteringNorDurabilityIsClaimed() { + SimpleBrokerProfile profile = SimpleBrokerProfile.inMemory(); + + assertThat(profile.localOnly()).isTrue(); + assertThat(profile.clusterSupported()).isFalse(); + assertThat(profile.durableAckSupported()).isFalse(); + } + + @Test + @DisplayName("a profile claiming a capability this broker lacks is refused") + void overclaimingIsRefused() { + // The claim would be believed, and the belief is what turns intermittent message loss into a + // fortnight of looking at application code. + assertThatThrownBy(() -> new SimpleBrokerProfile(true, true, false)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("does not exist"); + assertThatThrownBy(() -> new SimpleBrokerProfile(false, false, false)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("activation outside local and test is refused") + void activationOutsideLocalAndTestIsRefused() { + // In a multi-node deployment it does not error: it delivers to whichever fraction of users + // happens to be on the publishing node. + assertThat(SimpleBrokerProfile.activatableUnder(List.of("local"))).isTrue(); + assertThat(SimpleBrokerProfile.activatableUnder(List.of("test"))).isTrue(); + assertThat(SimpleBrokerProfile.activatableUnder(List.of())).isTrue(); + assertThat(SimpleBrokerProfile.activatableUnder(List.of("prod"))).isFalse(); + assertThat(SimpleBrokerProfile.activatableUnder(List.of("local", "prod"))).isFalse(); + } + + @Test + @DisplayName("the limitations are written down where an operator reads them") + void limitationsAreRecorded() { + assertThat(SimpleBrokerProfile.inMemory().supportMatrix()) + .anyMatch(line -> line.startsWith("cluster: unsupported")) + .anyMatch(line -> line.startsWith("durable ack: unsupported")) + .anyMatch(line -> line.contains("never promoted to APPLICATION_COMMIT")); + } +} diff --git a/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/StompAuthorizationPolicyTest.java b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/StompAuthorizationPolicyTest.java new file mode 100644 index 00000000..ce91de7c --- /dev/null +++ b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/StompAuthorizationPolicyTest.java @@ -0,0 +1,143 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.stomp; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Optional; +import java.util.Set; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * The whole decision table for an inbound STOMP frame. + * + *

Exercised as a value, without a channel, because an authorization rule reachable only through + * a {@code MessageChannel} gets tested for the two cases somebody bothered to build a channel for — + * and the ones nobody built are the ones that let a frame through. + */ +@Tag("websocket-advanced") +class StompAuthorizationPolicyTest { + + private static final StompDestinationCatalog CATALOG = + StompDestinationCatalog.using(StompProfile.conventional()) + .declare("/topic/orders", "orders:read", StompOperation.SUBSCRIBE) + .declare("/app/orders", "orders:write", StompOperation.SEND) + .declare("/user/queue/replies", "replies:read", StompOperation.SUBSCRIBE) + .declare("/user/queue/nudge", "nudge:send", StompOperation.SEND) + .build(); + + private static final StompAuthorizationPolicy POLICY = new StompAuthorizationPolicy(CATALOG); + + private static StompFrameRequest frame( + StompOperation operation, String destination, String principal, String... permissions) { + return new StompFrameRequest( + operation, + destination, + Optional.ofNullable(principal), + Set.of(permissions), + Optional.empty()); + } + + @Test + @DisplayName("a declared destination with the permission is allowed") + void declaredAndPermittedIsAllowed() { + assertThat( + POLICY + .decide(frame(StompOperation.SUBSCRIBE, "/topic/orders", "alice", "orders:read")) + .allowed()) + .isTrue(); + } + + @Test + @DisplayName("an unauthenticated frame is refused before anything else is considered") + void unauthenticatedIsRefusedFirst() { + assertThat( + POLICY + .decide(frame(StompOperation.SUBSCRIBE, "/topic/orders", null, "orders:read")) + .reason()) + .contains(StompRefusal.UNAUTHENTICATED); + assertThat(POLICY.decide(frame(StompOperation.SUBSCRIBE, "/topic/orders", " ")).reason()) + .contains(StompRefusal.UNAUTHENTICATED); + } + + @Test + @DisplayName("an undeclared destination is refused") + void undeclaredDestinationIsRefused() { + assertThat( + POLICY + .decide(frame(StompOperation.SUBSCRIBE, "/topic/secrets", "alice", "orders:read")) + .reason()) + .contains(StompRefusal.UNDECLARED_DESTINATION); + } + + @Test + @DisplayName("reading a destination does not grant writing to it") + void readDoesNotGrantWrite() { + // The distinct reason matters: an operator reading the metric can tell a missing declaration + // from a deliberately read-only feed. + assertThat( + POLICY + .decide(frame(StompOperation.SEND, "/topic/orders", "alice", "orders:read")) + .reason()) + .contains(StompRefusal.OPERATION_NOT_PERMITTED); + } + + @Test + @DisplayName("an authenticated caller without the permission is refused") + void missingPermissionIsRefused() { + assertThat(POLICY.decide(frame(StompOperation.SUBSCRIBE, "/topic/orders", "alice")).reason()) + .contains(StompRefusal.MISSING_PERMISSION); + } + + @Test + @DisplayName("a self-addressed user destination resolves to the caller") + void selfAddressedUserDestinationIsAllowed() { + // "/user/queue/replies" is Spring's own form: the broker resolves it against the session's + // principal, so there is nobody else it could reach. + assertThat( + POLICY + .decide( + frame(StompOperation.SUBSCRIBE, "/user/queue/replies", "alice", "replies:read")) + .allowed()) + .isTrue(); + } + + @Test + @DisplayName("a user destination naming somebody else is refused") + void foreignUserDestinationIsRefused() { + // "/user/bob/queue/nudge" is how one client reaches another's private queue. + assertThat( + POLICY + .decide(frame(StompOperation.SEND, "/user/bob/queue/nudge", "alice", "nudge:send")) + .reason()) + .contains(StompRefusal.FOREIGN_USER_DESTINATION); + assertThat( + POLICY + .decide( + frame(StompOperation.SEND, "/user/alice/queue/nudge", "alice", "nudge:send")) + .allowed()) + .isTrue(); + } + + @Test + @DisplayName("an unrecognised ack header is refused rather than defaulted") + void unrecognisedAckModeIsRefused() { + StompFrameRequest request = + new StompFrameRequest( + StompOperation.SUBSCRIBE, + "/topic/orders", + Optional.of("alice"), + Set.of("orders:read"), + Optional.of("eventually")); + + assertThat(POLICY.decide(request).reason()).contains(StompRefusal.UNSUPPORTED_ACK_MODE); + } + + @Test + @DisplayName("a decision is either an allow or a stated reason, never both or neither") + void decisionsAreWellFormed() { + assertThat(StompAuthorizationDecision.allow().reason()).isEmpty(); + assertThat(StompAuthorizationDecision.refuse(StompRefusal.MISSING_PERMISSION).allowed()) + .isFalse(); + } +} diff --git a/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/StompBrokerExclusivityTest.java b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/StompBrokerExclusivityTest.java new file mode 100644 index 00000000..03e79c48 --- /dev/null +++ b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/StompBrokerExclusivityTest.java @@ -0,0 +1,62 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.stomp; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * Configurations in which the broker that ends up running is not the one anybody configured. + * + *

All four cases start cleanly and log nothing. That is the reason for a startup check rather + * than a comment. + */ +@Tag("websocket-advanced") +class StompBrokerExclusivityTest { + + @Test + @DisplayName("the advanced adapter with exactly one broker is accepted") + void oneAdapterAndOneBrokerIsAccepted() { + StompBrokerExclusivity.verify(false, true, true, false); + StompBrokerExclusivity.verify(false, true, false, true); + assertThat(StompBrokerExclusivity.valid(false, true, true, false)).isTrue(); + } + + @Test + @DisplayName("the legacy channel alone is accepted") + void legacyChannelAloneIsAccepted() { + StompBrokerExclusivity.verify(true, false, false, false); + assertThat(StompBrokerExclusivity.valid(true, false, false, false)).isTrue(); + } + + @Test + @DisplayName("two STOMP runtimes are refused") + void twoRuntimesAreRefused() { + // Each configures the broker; the one that wins is decided by bean ordering, and the prefixes + // in effect are not the ones in either configuration file. + assertThatThrownBy(() -> StompBrokerExclusivity.verify(true, true, true, false)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("decided by bean ordering"); + } + + @Test + @DisplayName("two brokers are refused") + void twoBrokersAreRefused() { + // Subscriptions land in one and publishes in the other. + assertThatThrownBy(() -> StompBrokerExclusivity.verify(false, true, true, true)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("alternatives"); + } + + @Test + @DisplayName("an adapter with no broker is refused") + void adapterWithoutABrokerIsRefused() { + // Every SUBSCRIBE would be accepted and nothing would ever be delivered, which from the + // client's side is indistinguishable from a quiet system. + assertThatThrownBy(() -> StompBrokerExclusivity.verify(false, true, false, false)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("no broker behind it"); + } +} diff --git a/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/StompDestinationCatalogTest.java b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/StompDestinationCatalogTest.java new file mode 100644 index 00000000..198587b0 --- /dev/null +++ b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/StompDestinationCatalogTest.java @@ -0,0 +1,92 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.stomp; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** The set of destinations that exist, declared rather than discovered. */ +@Tag("websocket-advanced") +class StompDestinationCatalogTest { + + private static StompDestinationCatalog.Builder builder() { + return StompDestinationCatalog.using(StompProfile.conventional()); + } + + @Test + @DisplayName("reading and writing a destination are separate declarations") + void readAndWriteAreSeparate() { + // The same feed is usually readable by many and writable by few. A catalog carrying one + // permission per destination would have to pick the looser of the two. + StompDestinationCatalog catalog = + builder() + .declare("/topic/orders", "orders:read", StompOperation.SUBSCRIBE) + .declare("/app/orders", "orders:write", StompOperation.SEND) + .build(); + + assertThat(catalog.permissionFor(StompOperation.SUBSCRIBE, "/topic/orders")) + .contains("orders:read"); + assertThat(catalog.permissionFor(StompOperation.SEND, "/topic/orders")).isEmpty(); + } + + @Test + @DisplayName("an undeclared destination has no permission at all") + void undeclaredDestinationHasNoPermission() { + // Empty, not a permissive default. A default here makes every unlisted destination reachable. + StompDestinationCatalog catalog = + builder().declare("/topic/orders", "orders:read", StompOperation.SUBSCRIBE).build(); + + assertThat(catalog.permissionFor(StompOperation.SUBSCRIBE, "/topic/secrets")).isEmpty(); + assertThat(catalog.declares(StompOperation.SUBSCRIBE, "/topic/secrets")).isFalse(); + } + + @Test + @DisplayName("a destination outside every prefix is refused at startup") + void unroutableDestinationIsRefusedAtStartup() { + // Dead on arrival, and startup is when somebody is still reading the output. + assertThatThrownBy( + () -> builder().declare("/admin/shutdown", "admin", StompOperation.SEND).build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("matches no declared prefix"); + } + + @Test + @DisplayName("a blank permission is refused") + void blankPermissionIsRefused() { + // It reads as "declared" while granting everyone access. + assertThatThrownBy(() -> builder().declare("/topic/orders", " ", StompOperation.SUBSCRIBE)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("blank permission"); + } + + @Test + @DisplayName("two permissions for one operation on one destination are refused") + void conflictingPermissionsAreRefused() { + assertThatThrownBy( + () -> + builder() + .declare("/topic/orders", "orders:read", StompOperation.SUBSCRIBE) + .declare("/topic/orders", "orders:admin", StompOperation.SUBSCRIBE)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("looser one would win"); + } + + @Test + @DisplayName("an empty catalog is refused") + void emptyCatalogIsRefused() { + // It refuses every frame, which is a misconfiguration rather than a lockdown. + assertThatThrownBy(() -> builder().build()) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("misconfiguration"); + } + + @Test + @DisplayName("a destination declared for no operation is refused") + void destinationWithNoOperationIsRefused() { + assertThatThrownBy(() -> builder().declare("/topic/orders", "orders:read")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("unreachable"); + } +} diff --git a/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/StompEvidenceTest.java b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/StompEvidenceTest.java new file mode 100644 index 00000000..91e296fa --- /dev/null +++ b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/StompEvidenceTest.java @@ -0,0 +1,105 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.stomp; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * The three STOMP signals that all look like success. + * + *

Each of these tests is one way somebody could report work as done when it is not. + */ +@Tag("websocket-advanced") +class StompEvidenceTest { + + @Test + @DisplayName("a RECEIPT is never a commit") + void receiptIsNeverACommit() { + // The single most consequential confusion in STOMP. A receipt is written by the protocol + // layer, which knows nothing about whether the work succeeded — so a client treating it as a + // commit shows a user their order was placed while the transaction is still open. + StompAckPolicy policy = StompAckPolicy.auto(); + + assertThat(policy.evidenceForReceipt()).isEqualTo(StompEvidence.PROTOCOL_RECEIPT); + assertThat(policy.evidenceForReceipt().provesCompletion()).isFalse(); + assertThat(policy.mayReport(StompEvidence.PROTOCOL_RECEIPT, StompEvidence.APPLICATION_COMMIT)) + .isFalse(); + } + + @Test + @DisplayName("only a commit or a client apply proves completion") + void onlyCommitAndClientApplyProveCompletion() { + assertThat(StompEvidence.FRAME_RECEIVED.provesCompletion()).isFalse(); + assertThat(StompEvidence.BROKER_DELIVERY.provesCompletion()).isFalse(); + assertThat(StompEvidence.BROKER_ACK.provesCompletion()).isFalse(); + assertThat(StompEvidence.APPLICATION_COMMIT.provesCompletion()).isTrue(); + assertThat(StompEvidence.CLIENT_APPLIED.provesCompletion()).isTrue(); + } + + @Test + @DisplayName("reporting a stage further along than what happened is refused") + void overstatingIsRefused() { + assertThat(StompEvidence.BROKER_ACK.wouldOverstate(StompEvidence.APPLICATION_COMMIT)).isTrue(); + assertThat(StompEvidence.APPLICATION_COMMIT.wouldOverstate(StompEvidence.BROKER_ACK)).isFalse(); + assertThat(StompEvidence.BROKER_ACK.wouldOverstate(StompEvidence.BROKER_ACK)).isFalse(); + } + + @Test + @DisplayName("the three ack modes are distinguished, not conflated") + void ackModesAreDistinguished() { + // They place responsibility for redelivery in different places, and a server that treats them + // alike is wrong for two of the three. + assertThat(StompAckMode.AUTO.requiresClientAcknowledgement()).isFalse(); + assertThat(StompAckMode.CLIENT.requiresClientAcknowledgement()).isTrue(); + assertThat(StompAckMode.CLIENT_INDIVIDUAL.requiresClientAcknowledgement()).isTrue(); + assertThat(StompAckMode.CLIENT.cumulative()).isTrue(); + assertThat(StompAckMode.CLIENT_INDIVIDUAL.cumulative()).isFalse(); + } + + @Test + @DisplayName("an unrecognised ack header is not defaulted") + void unrecognisedAckHeaderIsNotDefaulted() { + // Defaulting to auto turns a client asking for at-least-once into one that silently gets + // at-most-once. + assertThat(StompAckMode.fromWire("client-individual")).contains(StompAckMode.CLIENT_INDIVIDUAL); + assertThat(StompAckMode.fromWire("CLIENT")).contains(StompAckMode.CLIENT); + assertThat(StompAckMode.fromWire("eventually")).isEmpty(); + assertThat(StompAckMode.fromWire(null)).isEmpty(); + } + + @Test + @DisplayName("a client-acknowledged subscription is bounded") + void clientAcknowledgedSubscriptionIsBounded() { + StompAckPolicy policy = new StompAckPolicy(StompAckMode.CLIENT, 2, Duration.ofSeconds(30)); + + assertThat(policy.mayDeliver(1)).isTrue(); + assertThat(policy.mayDeliver(2)).isFalse(); + assertThat(StompAckPolicy.auto().mayDeliver(9999)).isTrue(); + } + + @Test + @DisplayName("a policy that could never deliver, or never bound, is refused") + void contradictoryPoliciesAreRefused() { + assertThatThrownBy(() -> new StompAckPolicy(StompAckMode.CLIENT, 0, Duration.ofSeconds(1))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("can never deliver"); + assertThatThrownBy(() -> new StompAckPolicy(StompAckMode.AUTO, 5, Duration.ofSeconds(1))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("does not exist"); + assertThatThrownBy(() -> new StompAckPolicy(StompAckMode.CLIENT, 1, Duration.ZERO)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("pin the retained messages"); + } + + @Test + @DisplayName("asking what an ACK proves on an auto subscription is a caller error") + void ackOnAutoSubscriptionIsACallerError() { + assertThatThrownBy(() -> StompAckPolicy.auto().evidenceForClientAck()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("wrong policy in hand"); + } +} diff --git a/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/StompProfileTest.java b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/StompProfileTest.java new file mode 100644 index 00000000..da12cf21 --- /dev/null +++ b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/StompProfileTest.java @@ -0,0 +1,74 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.stomp; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.Set; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** What a destination prefix decides, and what it does not. */ +@Tag("websocket-advanced") +class StompProfileTest { + + @Test + @DisplayName("each prefix routes to its own role") + void prefixesRouteToTheirRole() { + StompProfile profile = StompProfile.conventional(); + + assertThat(profile.roleOf("/app/orders")).contains(StompDestinationRole.APPLICATION); + assertThat(profile.roleOf("/topic/orders")).contains(StompDestinationRole.BROKER); + assertThat(profile.roleOf("/user/queue/replies")).contains(StompDestinationRole.USER); + } + + @Test + @DisplayName("an unmatched destination is refused, not defaulted") + void unmatchedDestinationIsRefused() { + // The destination is a free string from the client, so unmatched is the normal shape of both a + // typo and an attempt to reach somewhere that was never published. + assertThat(StompProfile.conventional().roleOf("/admin/shutdown")).isEmpty(); + assertThat(StompProfile.conventional().roleOf("")).isEmpty(); + } + + @Test + @DisplayName("a prefix match stops at a segment boundary") + void prefixMatchStopsAtASegmentBoundary() { + // "/topicprivate" is not under "/topic", and treating it as though it were hands the caller a + // destination the operator never published. + assertThat(StompProfile.conventional().roleOf("/topicprivate/secrets")).isEmpty(); + assertThat(StompProfile.conventional().roleOf("/topic")).contains(StompDestinationRole.BROKER); + } + + @Test + @DisplayName("overlapping prefixes are refused") + void overlappingPrefixesAreRefused() { + // A destination matching two roles would be dispatched by whichever check ran first, which is + // an ordering detail deciding an authorization outcome. + assertThatThrownBy( + () -> new StompProfile(Set.of("/app"), Set.of("/app/broker"), Set.of("/user"), true)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("whichever check ran first"); + } + + @Test + @DisplayName("a profile that routes nothing is refused") + void emptyProfileIsRefused() { + assertThatThrownBy(() -> new StompProfile(Set.of(), Set.of(), Set.of("/user"), true)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("routes nothing"); + } + + @Test + @DisplayName("a malformed prefix is refused at construction") + void malformedPrefixIsRefused() { + assertThatThrownBy( + () -> new StompProfile(Set.of("app"), Set.of("/topic"), Set.of("/user"), true)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("absolute normalized path"); + assertThatThrownBy( + () -> new StompProfile(Set.of("/app/"), Set.of("/topic"), Set.of("/user"), true)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("absolute normalized path"); + } +} diff --git a/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/rabbit/MultiNodeUserDestinationTest.java b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/rabbit/MultiNodeUserDestinationTest.java new file mode 100644 index 00000000..09702be1 --- /dev/null +++ b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/rabbit/MultiNodeUserDestinationTest.java @@ -0,0 +1,126 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.stomp.rabbit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketNodeId; +import java.time.Duration; +import java.time.Instant; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** Reaching a user who is connected somewhere else, without the cluster eating itself. */ +@Tag("websocket-advanced") +class MultiNodeUserDestinationTest { + + private static final Instant NOW = Instant.parse("2026-08-25T10:00:00Z"); + private static final WebSocketNodeId LOCAL = new WebSocketNodeId("edge-1"); + private static final WebSocketNodeId REMOTE = new WebSocketNodeId("edge-2"); + private static final String ALICE = "fp-alice"; + + private static MultiNodeUserDestination routing() { + return new MultiNodeUserDestination(UserDestinationPolicy.conventional(), LOCAL); + } + + @Test + @DisplayName("a locally held session is delivered to directly") + void localSessionIsDeliveredDirectly() { + MultiNodeUserDestination routing = routing(); + routing.register(ALICE, NOW); + + assertThat(routing.route(ALICE, false, NOW).action()) + .isEqualTo(UserDestinationAction.DELIVER_LOCALLY); + assertThat(routing.route(ALICE, false, NOW).destination()).isEmpty(); + } + + @Test + @DisplayName("a session held elsewhere is broadcast once") + void remoteSessionIsBroadcast() { + MultiNodeUserDestination routing = routing(); + routing.observe(ALICE, REMOTE, NOW); + + assertThat(routing.route(ALICE, false, NOW).action()) + .isEqualTo(UserDestinationAction.BROADCAST); + assertThat(routing.route(ALICE, false, NOW).destination()).contains("/topic/unresolved-user"); + } + + @Test + @DisplayName("an already-broadcast message is never broadcast again") + void broadcastMessagesDoNotLoop() { + // Without this, every node rebroadcasts every unresolvable message on receipt and the cluster + // spends its whole broker budget passing one undeliverable message around — which from any + // single node looks like ordinary traffic. + MultiNodeUserDestination routing = routing(); + + assertThat(routing.route("fp-nobody", true, NOW).action()) + .isEqualTo(UserDestinationAction.UNRESOLVED); + assertThat(routing.route("fp-nobody", true, NOW).destination()) + .contains("/topic/unresolved-user-dlq"); + } + + @Test + @DisplayName("an unknown user is broadcast, not dropped") + void unknownUserIsBroadcast() { + assertThat(routing().route("fp-nobody", false, NOW).action()) + .isEqualTo(UserDestinationAction.BROADCAST); + } + + @Test + @DisplayName("an expired mapping is treated as absent, not as disconnected") + void expiredMappingIsAbsentNotDisconnected() { + // The difference is that absent still broadcasts. Treating it as "not connected" drops the + // message for a user who is connected to a node that merely stopped refreshing. + MultiNodeUserDestination routing = routing(); + routing.register(ALICE, NOW); + Instant late = NOW.plus(Duration.ofMinutes(3)); + + assertThat(routing.route(ALICE, false, late).action()) + .isEqualTo(UserDestinationAction.BROADCAST); + } + + @Test + @DisplayName("a stale mapping is evicted so it stops routing at a machine that is gone") + void staleMappingsAreEvicted() { + MultiNodeUserDestination routing = routing(); + routing.observe(ALICE, REMOTE, NOW); + routing.register("fp-bob", NOW.plus(Duration.ofMinutes(3))); + + assertThat(routing.evictExpired(NOW.plus(Duration.ofMinutes(3)))).isOne(); + assertThat(routing.trackedUsers()).isOne(); + } + + @Test + @DisplayName("disconnecting releases the mapping and its broker queue") + void disconnectReleasesTheMapping() { + // A relay creates one temporary queue per user-destination subscription. Without this they + // accumulate for every session that ever connected, and RabbitMQ's memory alarm fires long + // before anybody connects the two facts. + MultiNodeUserDestination routing = routing(); + routing.register(ALICE, NOW); + + assertThat(routing.deregister(ALICE)).isTrue(); + assertThat(routing.deregister(ALICE)).isFalse(); + assertThat(routing.trackedUsers()).isZero(); + } + + @Test + @DisplayName("the routing action is the metric tag, never the destination") + void routingCarriesABoundedTag() { + // A user destination contains a user identifier by construction, so tagging a metric with it + // publishes every user's name and gives the cardinality budget an unbounded denominator. + assertThat(UserDestinationAction.values()).hasSize(3); + } + + @Test + @DisplayName("a policy that would loop or never expire is refused") + void loopingOrImmortalPolicyIsRefused() { + assertThatThrownBy( + () -> new UserDestinationPolicy("/topic/a", "/topic/a", Duration.ofMinutes(1))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("forever"); + assertThatThrownBy(() -> new UserDestinationPolicy("/topic/a", "/topic/b", Duration.ZERO)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("no longer exist"); + } +} diff --git a/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/rabbit/RabbitBrokerRelayProfileTest.java b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/rabbit/RabbitBrokerRelayProfileTest.java new file mode 100644 index 00000000..6ead249d --- /dev/null +++ b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/advanced/stomp/rabbit/RabbitBrokerRelayProfileTest.java @@ -0,0 +1,65 @@ +package dev.caskeleton.adapter.inbound.websocket.advanced.stomp.rabbit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * The relay's cost and its failure model. + * + *

Enabling it moves delivery's dependency from this JVM to a separate machine over a network, + * and the platform's own health check keeps reporting green throughout an outage. + */ +@Tag("websocket-advanced") +class RabbitBrokerRelayProfileTest { + + @Test + @DisplayName("a relay without a heartbeat is refused") + void heartbeatlessRelayIsRefused() { + // A half-open TCP connection to a dead broker accepts every publish silently. The heartbeat is + // the only thing that notices. + assertThatThrownBy(() -> new RabbitBrokerRelayProfile("broker", 61613, true, Duration.ZERO)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("quiet broker from a dead one"); + } + + @Test + @DisplayName("broker connection cost is one per session plus the shared system connection") + void connectionCostIsComputable() { + // Worth computing before the first outage. A broker connection limit turns a normal-looking + // client count into a hard ceiling, and the symptom is new users failing to subscribe while + // existing ones are fine. + RabbitBrokerRelayProfile profile = RabbitBrokerRelayProfile.localPlaintext(); + + assertThat(profile.brokerConnectionsFor(0)).isOne(); + assertThat(profile.brokerConnectionsFor(500)).isEqualTo(501); + } + + @Test + @DisplayName("a plaintext relay says what it exposes") + void plaintextRelayReportsItsExposure() { + assertThat(RabbitBrokerRelayProfile.localPlaintext().productionConcerns()) + .singleElement() + .asString() + .contains("clear text"); + assertThat( + new RabbitBrokerRelayProfile("broker", 61614, true, Duration.ofSeconds(10)) + .productionConcerns()) + .isEmpty(); + } + + @Test + @DisplayName("a blank host and an impossible port are refused") + void malformedTargetIsRefused() { + assertThatThrownBy(() -> new RabbitBrokerRelayProfile(" ", 61613, true, Duration.ofSeconds(1))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("never the broker that was meant"); + assertThatThrownBy(() -> new RabbitBrokerRelayProfile("broker", 0, true, Duration.ofSeconds(1))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("not a port"); + } +} diff --git a/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/arch/WebSocketArchitectureRulesTest.java b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/arch/WebSocketArchitectureRulesTest.java new file mode 100644 index 00000000..78b5b088 --- /dev/null +++ b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/arch/WebSocketArchitectureRulesTest.java @@ -0,0 +1,91 @@ +package dev.caskeleton.adapter.inbound.websocket.arch; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.core.importer.ClassFileImporter; +import com.tngtech.archunit.core.importer.ImportOption; +import com.tngtech.archunit.lang.ArchRule; +import dev.caskeleton.adapter.inbound.websocket.testkit.arch.WebSocketArchitectureRules; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * The rule pack, applied to this leaf and proven to be capable of failing. + * + *

Both halves are needed. A rule that passes against a clean tree looks identical to a rule that + * matches nothing — a typo in a package name produces a permanently green check, which is worse + * than no check because it is believed. + */ +class WebSocketArchitectureRulesTest { + + private static JavaClasses production; + + @BeforeAll + static void importProduction() { + production = + new ClassFileImporter() + .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS) + .importPackages("dev.caskeleton.adapter.inbound.websocket"); + } + + @Test + @DisplayName("the import actually found the platform") + void importFoundThePlatform() { + // Without this, every rule below passes against an empty class set. + assertThat(production.size()).isGreaterThan(30); + } + + @Test + @DisplayName("every rule holds against this leaf") + void everyRuleHolds() { + for (ArchRule rule : WebSocketArchitectureRules.all()) { + assertThatCode(() -> rule.check(production)) + .as("%s", rule.getDescription()) + .doesNotThrowAnyException(); + } + } + + @Test + @DisplayName("the handler rule can actually fail") + void theHandlerRuleCanFail() { + // Applied to a package that does hold transport types, the rule must object. A rule that + // cannot fail is a rule that is checking a package name nobody spelled correctly. + JavaClasses stompTransport = + new ClassFileImporter() + .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS) + .importPackages("dev.caskeleton.adapter.inbound.websocket.stomp"); + + assertThat(stompTransport.size()).isPositive(); + assertThatThrownBy( + () -> + com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses() + .that() + .resideInAPackage("dev.caskeleton.adapter.inbound.websocket.stomp..") + .should() + .dependOnClassesThat() + .resideInAnyPackage("org.springframework.web.socket..") + .check(stompTransport)) + .isInstanceOf(AssertionError.class); + } + + @Test + @DisplayName("the handler package is genuinely free of transport types") + void handlerPackageIsFreeOfTransport() { + // Stated separately from the pack so the failure names this specific boundary. It is the one + // the entire outbound design rests on. + WebSocketArchitectureRules.handlersDoNotTouchTheTransport().check(production); + + assertThat( + production.stream() + .filter( + type -> + type.getPackageName() + .startsWith("dev.caskeleton.adapter.inbound.websocket.handler")) + .count()) + .isPositive(); + } +} diff --git a/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/authz/MessageAuthorizationPolicyTest.java b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/authz/MessageAuthorizationPolicyTest.java new file mode 100644 index 00000000..7d06f22e --- /dev/null +++ b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/authz/MessageAuthorizationPolicyTest.java @@ -0,0 +1,70 @@ +package dev.caskeleton.adapter.inbound.websocket.authz; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.inbound.websocket.protocol.WebSocketMessageType; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Authorization per message rather than per connection. + * + *

A connection is authorised once and lives for hours. Authorising there means the caller keeps + * whatever it had when it connected — through every role change and every revocation — until it + * happens to reconnect. + */ +class MessageAuthorizationPolicyTest { + + private static final WebSocketMessageType READ = new WebSocketMessageType("order.read.v1"); + private static final WebSocketMessageType CANCEL = new WebSocketMessageType("order.cancel.v1"); + private static final WebSocketMessageType UNDECLARED = + new WebSocketMessageType("order.refund.v1"); + + private final MessageAuthorizationPolicy policy = + MessageAuthorizationPolicy.of(Map.of(READ, Set.of(), CANCEL, Set.of("order:cancel"))); + + @Test + @DisplayName("a declared type with no authorities needs only authentication") + void authenticatedOnlyTypeIsPermitted() { + assertThat(policy.permits(READ, Set.of())).isTrue(); + } + + @Test + @DisplayName("a type requiring an authority refuses a caller without it") + void missingAuthorityIsRefused() { + assertThat(policy.permits(CANCEL, Set.of())).isFalse(); + assertThat(policy.permits(CANCEL, Set.of("order:read"))).isFalse(); + assertThat(policy.permits(CANCEL, Set.of("order:cancel"))).isTrue(); + } + + @Test + @DisplayName("authorities are evaluated per message, so a revocation takes effect immediately") + void authoritiesAreEvaluatedPerMessage() { + // The same connection, two different moments. Per-connection authorization would give the same + // answer both times. + assertThat(policy.permits(CANCEL, Set.of("order:cancel"))).isTrue(); + assertThat(policy.permits(CANCEL, Set.of())).isFalse(); + } + + @Test + @DisplayName("an undeclared type is refused, not allowed") + void undeclaredTypeIsRefused() { + // A new message type is added by someone thinking about the message, not the permission. + // Defaulting to allow ships the omission. + assertThat(policy.permits(UNDECLARED, Set.of("order:cancel", "admin"))).isFalse(); + } + + @Test + @DisplayName("undeclared published types are reported so the omission is visible") + void undeclaredTypesAreReported() { + // Refusing them is safe and is not what anybody intended, so the deployment is told rather + // than left to hear it from a client that cannot do anything. + List undeclared = + policy.undeclaredAmong(List.of(READ, CANCEL, UNDECLARED)); + + assertThat(undeclared).containsExactly(UNDECLARED); + } +} diff --git a/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/budget/WebSocketConnectionBudgetTest.java b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/budget/WebSocketConnectionBudgetTest.java new file mode 100644 index 00000000..7a0dc115 --- /dev/null +++ b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/budget/WebSocketConnectionBudgetTest.java @@ -0,0 +1,141 @@ +package dev.caskeleton.adapter.inbound.websocket.budget; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * The limits HTTP gave for free and a connection does not. + * + *

A request has a finite body, one response and a lifetime in seconds. A connection has none of + * those, so whatever is not bounded here is unbounded in production — and the symptom is never a + * failing request. It is heap. + */ +class WebSocketConnectionBudgetTest { + + @Test + @DisplayName("the standard budget bounds every dimension") + void standardBudgetBoundsEverything() { + WebSocketConnectionBudget budget = WebSocketConnectionBudget.standard(); + + assertThat(budget.maxFrameBytes()).isPositive(); + assertThat(budget.maxMessageBytes()).isPositive(); + assertThat(budget.maxFragments()).isPositive(); + assertThat(budget.maxInboundMessagesPerSecond()).isPositive(); + assertThat(budget.maxBufferedOutboundBytes()).isPositive(); + assertThat(budget.maxConnectionAge()).isPositive(); + assertThat(budget.idleTimeout()).isPositive(); + } + + @Test + @DisplayName("the outbound buffer is bounded, because a slow consumer never fails") + void outboundBufferIsBounded() { + // A slow consumer reads more slowly than the server writes and the difference accumulates in + // the server's heap. One is invisible; a hundred is an OutOfMemoryError with no failing + // request to point at. + assertThatThrownBy( + () -> + new WebSocketConnectionBudget( + 64 * 1024, + 512 * 1024, + 16, + 100, + 64L * 1024 * 1024, + Duration.ofHours(4), + Duration.ofSeconds(90))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("multiplied by the connection count is the heap"); + } + + @Test + @DisplayName("a message bound below the frame bound is refused") + void messageBoundBelowFrameBoundIsRefused() { + // It would accept a frame and then refuse it on reassembly, spending exactly the memory the + // frame bound was there to save. + assertThatThrownBy( + () -> + new WebSocketConnectionBudget( + 64 * 1024, + 32 * 1024, + 16, + 100, + 1024L * 1024, + Duration.ofHours(4), + Duration.ofSeconds(90))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("will then refuse"); + } + + @Test + @DisplayName("the three size bounds must be able to describe the same message") + void sizeBoundsMustBeCoherent() { + // frame x fragments below the message bound means the largest message the profile claims to + // accept can never actually arrive. + assertThatThrownBy( + () -> + new WebSocketConnectionBudget( + 64 * 1024, + 512 * 1024, + 2, + 100, + 1024L * 1024, + Duration.ofHours(4), + Duration.ofSeconds(90))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("can never be delivered"); + } + + @Test + @DisplayName("a connection with no maximum age is refused") + void unboundedAgeIsRefused() { + assertThatThrownBy( + () -> + new WebSocketConnectionBudget( + 64 * 1024, + 512 * 1024, + 16, + 100, + 1024L * 1024, + Duration.ZERO, + Duration.ofSeconds(90))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("unbounded"); + } + + @Test + @DisplayName("an idle timeout beyond the maximum age never fires") + void idleTimeoutBeyondMaxAgeIsRefused() { + assertThatThrownBy( + () -> + new WebSocketConnectionBudget( + 64 * 1024, + 512 * 1024, + 16, + 100, + 1024L * 1024, + Duration.ofMinutes(1), + Duration.ofHours(1))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("never fires"); + } + + @Test + @DisplayName("no profile may exceed the platform ceilings") + void platformCeilingsHold() { + assertThatThrownBy( + () -> + new WebSocketConnectionBudget( + WebSocketConnectionBudget.ABSOLUTE_FRAME_MAX + 1, + WebSocketConnectionBudget.ABSOLUTE_MESSAGE_MAX, + 64, + 100, + 1024L * 1024, + Duration.ofHours(4), + Duration.ofSeconds(90))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("ceiling"); + } +} diff --git a/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/codec/WebSocketJsonWireManifestTest.java b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/codec/WebSocketJsonWireManifestTest.java new file mode 100644 index 00000000..1a624d41 --- /dev/null +++ b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/codec/WebSocketJsonWireManifestTest.java @@ -0,0 +1,197 @@ +package dev.caskeleton.adapter.inbound.websocket.codec; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.inbound.websocket.budget.WebSocketConnectionBudget; +import dev.caskeleton.adapter.inbound.websocket.error.WebSocketFailureCategory; +import dev.caskeleton.adapter.inbound.websocket.protocol.WebSocketMessageCatalog; +import dev.caskeleton.adapter.inbound.websocket.protocol.WebSocketMessageDescriptor; +import dev.caskeleton.adapter.inbound.websocket.protocol.WebSocketMessageDirection; +import dev.caskeleton.adapter.inbound.websocket.protocol.WebSocketMessageFamily; +import dev.caskeleton.adapter.inbound.websocket.protocol.WebSocketMessageType; +import dev.caskeleton.adapter.inbound.websocket.protocol.WebSocketSchemaVersion; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * The decode path, which is the cheapest place to attack a socket. + * + *

It runs on every inbound frame, before most policy, on bytes chosen entirely by the peer. Each + * case here is a document a permissive parser accepts and this one must not. + */ +class WebSocketJsonWireManifestTest { + + /** A wire type: a record, so nothing runs while it is populated. */ + public record PlaceOrder(String sku, int quantity) {} + + private static final WebSocketMessageType PLACE = new WebSocketMessageType("order.place.v1"); + private static final WebSocketMessageType PLACED = new WebSocketMessageType("order.placed.v1"); + + private final WebSocketMessageCatalog catalog = + WebSocketMessageCatalog.of( + List.of( + new WebSocketMessageDescriptor( + PLACE, + WebSocketMessageFamily.COMMAND, + WebSocketMessageDirection.CLIENT_TO_SERVER, + WebSocketSchemaVersion.v(1)), + new WebSocketMessageDescriptor( + PLACED, + WebSocketMessageFamily.EVENT, + WebSocketMessageDirection.SERVER_TO_CLIENT, + WebSocketSchemaVersion.v(1)))); + + private final StrictWebSocketJsonCodec codec = + new StrictWebSocketJsonCodec( + catalog, + WebSocketWireTypeManifest.of(Map.of(PLACE, PlaceOrder.class)), + WebSocketConnectionBudget.standard()); + + @Test + @DisplayName("a well-formed document decodes") + void wellFormedDocumentDecodes() { + Object decoded = + codec.decodeFromClient( + PLACE, WebSocketMessageFamily.COMMAND, "{\"sku\":\"A-1\",\"quantity\":2}"); + + assertThat(decoded).isEqualTo(new PlaceOrder("A-1", 2)); + } + + @Test + @DisplayName("a duplicate key is refused") + void duplicateKeyIsRefused() { + // Every parser accepts this and silently keeps one value, so the same document means different + // things to a validating proxy and to this server — a request that passes review at one layer + // and executes as something else at the next. + assertThatThrownBy( + () -> + codec.decodeFromClient( + PLACE, + WebSocketMessageFamily.COMMAND, + "{\"sku\":\"A-1\",\"quantity\":2,\"quantity\":99999}")) + .isInstanceOf(WebSocketDecodeException.class); + } + + @Test + @DisplayName("an unknown field is refused") + void unknownFieldIsRefused() { + // Accepting it means a client can send anything alongside the real fields and get no signal + // that a typo'd field name was ignored. + assertThatThrownBy( + () -> + codec.decodeFromClient( + PLACE, + WebSocketMessageFamily.COMMAND, + "{\"sku\":\"A-1\",\"quantity\":2,\"admin\":true}")) + .isInstanceOf(WebSocketDecodeException.class); + } + + @Test + @DisplayName("trailing content after the document is refused") + void trailingContentIsRefused() { + // Two documents in one frame: a proxy that reads the first and a server that reads both is the + // same smuggling shape as a duplicate key. + assertThatThrownBy( + () -> + codec.decodeFromClient( + PLACE, + WebSocketMessageFamily.COMMAND, + "{\"sku\":\"A-1\",\"quantity\":2}{\"sku\":\"B-2\",\"quantity\":9}")) + .isInstanceOf(WebSocketDecodeException.class); + } + + @Test + @DisplayName("deeply nested content is refused before it is parsed") + void deepNestingIsRefused() { + // Depth costs stack during parse, so a bound applied afterwards has already paid for the + // attack. + String deep = "[".repeat(200) + "]".repeat(200); + + assertThatThrownBy(() -> codec.decodeFromClient(PLACE, WebSocketMessageFamily.COMMAND, deep)) + .isInstanceOf(WebSocketDecodeException.class); + } + + @Test + @DisplayName("a case-different field name is refused") + void caseInsensitiveBindingIsOff() { + assertThatThrownBy( + () -> + codec.decodeFromClient( + PLACE, WebSocketMessageFamily.COMMAND, "{\"SKU\":\"A-1\",\"quantity\":2}")) + .isInstanceOf(WebSocketDecodeException.class); + } + + @Test + @DisplayName("a float where an int belongs is refused") + void floatForIntIsRefused() { + assertThatThrownBy( + () -> + codec.decodeFromClient( + PLACE, WebSocketMessageFamily.COMMAND, "{\"sku\":\"A-1\",\"quantity\":2.7}")) + .isInstanceOf(WebSocketDecodeException.class); + } + + @Test + @DisplayName("an unpublished type never reaches the parser") + void unpublishedTypeNeverReachesTheParser() { + // Catalog first, parser second. The bytes are not handed to a parser at all. + assertThatThrownBy( + () -> + codec.decodeFromClient( + new WebSocketMessageType("secret.internal.v1"), + WebSocketMessageFamily.COMMAND, + "{}")) + .isInstanceOf(WebSocketDecodeException.class) + .extracting(failure -> ((WebSocketDecodeException) failure).category()) + .isEqualTo(WebSocketFailureCategory.UNKNOWN_TYPE); + } + + @Test + @DisplayName("a server-only type sent by a client is refused") + void serverOnlyTypeFromClientIsRefused() { + assertThatThrownBy(() -> codec.decodeFromClient(PLACED, WebSocketMessageFamily.EVENT, "{}")) + .isInstanceOf(WebSocketDecodeException.class); + } + + @Test + @DisplayName("the decode failure tells the peer nothing about the server") + void decodeFailureDoesNotLeak() { + // A parser's message names the class, the field and often the offending content. Useful in a + // server log, a disclosure on a connection held for hours. + WebSocketDecodeException failure = + org.junit.jupiter.api.Assertions.assertThrows( + WebSocketDecodeException.class, + () -> + codec.decodeFromClient( + PLACE, WebSocketMessageFamily.COMMAND, "{\"sku\":\"A-1\",\"admin\":true}")); + + assertThat(failure.getMessage()).doesNotContain("PlaceOrder"); + assertThat(failure.getMessage()).doesNotContain("admin"); + assertThat(failure.getMessage()).doesNotContain("dev.caskeleton"); + } + + @Test + @DisplayName("only records may be wire types") + void onlyRecordsMayBeWireTypes() { + // A record cannot run arbitrary code while being populated, which is the mechanism every + // deserialization gadget depends on. + assertThatThrownBy(() -> WebSocketWireTypeManifest.of(Map.of(PLACE, java.util.HashMap.class))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("deserialization gadget"); + assertThatCode(() -> WebSocketWireTypeManifest.of(Map.of(PLACE, PlaceOrder.class))) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("the server may not encode a type it is not published to send") + void serverCannotEncodeAClientOnlyType() { + assertThatThrownBy(() -> codec.encodeToClient(PLACE, new PlaceOrder("A-1", 1))) + .isInstanceOf(WebSocketDecodeException.class); + assertThat(codec.encodeToClient(PLACED, new PlaceOrder("A-1", 1))) + .isEqualTo("{\"sku\":\"A-1\",\"quantity\":1}"); + } +} diff --git a/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/config/WebSocketPlatformStartupValidatorTest.java b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/config/WebSocketPlatformStartupValidatorTest.java new file mode 100644 index 00000000..a8cad351 --- /dev/null +++ b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/config/WebSocketPlatformStartupValidatorTest.java @@ -0,0 +1,216 @@ +package dev.caskeleton.adapter.inbound.websocket.config; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.inbound.websocket.authz.MessageAuthorizationPolicy; +import dev.caskeleton.adapter.inbound.websocket.budget.WebSocketConnectionBudget; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketEndpointCatalog; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketEndpointName; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketEndpointProfile; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketStackProfile; +import dev.caskeleton.adapter.inbound.websocket.protocol.WebSocketMessageCatalog; +import dev.caskeleton.adapter.inbound.websocket.protocol.WebSocketMessageDescriptor; +import dev.caskeleton.adapter.inbound.websocket.protocol.WebSocketMessageDirection; +import dev.caskeleton.adapter.inbound.websocket.protocol.WebSocketMessageFamily; +import dev.caskeleton.adapter.inbound.websocket.protocol.WebSocketMessageType; +import dev.caskeleton.adapter.inbound.websocket.protocol.WebSocketProtocolProfile; +import dev.caskeleton.adapter.inbound.websocket.protocol.WebSocketSchemaVersion; +import dev.caskeleton.adapter.inbound.websocket.security.WebSocketAuthenticationProfile; +import dev.caskeleton.adapter.inbound.websocket.security.WebSocketOriginPolicy; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Configurations that work perfectly in development and are wrong in production. + * + *

That is the entire category this validator covers. A cookie endpoint with no origin allowlist + * serves every same-origin test correctly; an unnegotiated-fallback profile connects fine until the + * message format changes; a type with no authorization requirement refuses everything, quietly, and + * only for the clients that try to use it. + */ +class WebSocketPlatformStartupValidatorTest { + + private static final WebSocketMessageType PLACE = new WebSocketMessageType("order.place.v1"); + + private final WebSocketPlatformStartupValidator production = + new WebSocketPlatformStartupValidator(true); + private final WebSocketPlatformStartupValidator local = + new WebSocketPlatformStartupValidator(false); + + private static WebSocketEndpointCatalog endpoints(boolean authenticated) { + return WebSocketEndpointCatalog.of( + List.of( + new WebSocketEndpointProfile( + new WebSocketEndpointName("live-updates"), + "/ws/v1/live", + WebSocketStackProfile.SERVLET, + authenticated, + WebSocketConnectionBudget.standard(), + 1000))); + } + + private static WebSocketMessageCatalog messages() { + return WebSocketMessageCatalog.of( + List.of( + new WebSocketMessageDescriptor( + PLACE, + WebSocketMessageFamily.COMMAND, + WebSocketMessageDirection.CLIENT_TO_SERVER, + WebSocketSchemaVersion.v(1)))); + } + + private static WebSocketOriginPolicy origins() { + return WebSocketOriginPolicy.browsersOnly(Set.of("https://app.example.com")); + } + + @Test + @DisplayName("a coherent production configuration starts") + void coherentConfigurationStarts() { + assertThatCode( + () -> + production.validate( + endpoints(true), + messages(), + MessageAuthorizationPolicy.of(Map.of(PLACE, Set.of())), + origins(), + WebSocketAuthenticationProfile.ONE_TIME_TICKET, + WebSocketProtocolProfile.stable())) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("a cookie profile with no origin allowlist fails startup") + void cookieProfileNeedsOrigins() { + assertThatThrownBy( + () -> + production.validate( + endpoints(true), + messages(), + MessageAuthorizationPolicy.of(Map.of(PLACE, Set.of())), + WebSocketOriginPolicy.browsersOnly(Set.of()), + WebSocketAuthenticationProfile.SESSION_COOKIE, + WebSocketProtocolProfile.stable())) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("the whole defence"); + } + + @Test + @DisplayName("the unnegotiated fallback fails production and passes local") + void fallbackIsProductionOnlyProblem() { + assertThatThrownBy( + () -> + production.validate( + endpoints(true), + messages(), + MessageAuthorizationPolicy.of(Map.of(PLACE, Set.of())), + origins(), + WebSocketAuthenticationProfile.ONE_TIME_TICKET, + WebSocketProtocolProfile.localCompatibility())) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("assume their own current format"); + + assertThatCode( + () -> + local.validate( + endpoints(true), + messages(), + MessageAuthorizationPolicy.of(Map.of(PLACE, Set.of())), + origins(), + WebSocketAuthenticationProfile.ONE_TIME_TICKET, + WebSocketProtocolProfile.localCompatibility())) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("an unauthenticated endpoint fails production startup") + void unauthenticatedEndpointFailsProduction() { + assertThatThrownBy( + () -> + production.validate( + endpoints(false), + messages(), + MessageAuthorizationPolicy.of(Map.of(PLACE, Set.of())), + origins(), + WebSocketAuthenticationProfile.ONE_TIME_TICKET, + WebSocketProtocolProfile.stable())) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("holding server memory for hours"); + } + + @Test + @DisplayName("a published type with no authorization requirement fails startup") + void undeclaredAuthorizationFailsStartup() { + // It is currently unsendable, which is safe and is not what anybody intended. Without this the + // deployment hears about it from a client that cannot do anything. + assertThatThrownBy( + () -> + production.validate( + endpoints(true), + messages(), + MessageAuthorizationPolicy.of(Map.of()), + origins(), + WebSocketAuthenticationProfile.ONE_TIME_TICKET, + WebSocketProtocolProfile.stable())) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("unsendable"); + } + + @Test + @DisplayName("endpoints spanning both runtimes fail startup") + void mixedRuntimesFailStartup() { + WebSocketEndpointCatalog mixed = + WebSocketEndpointCatalog.of( + List.of( + new WebSocketEndpointProfile( + new WebSocketEndpointName("live-updates"), + "/ws/v1/live", + WebSocketStackProfile.SERVLET, + true, + WebSocketConnectionBudget.standard(), + 1000), + new WebSocketEndpointProfile( + new WebSocketEndpointName("reactive-feed"), + "/ws/v1/reactive", + WebSocketStackProfile.REACTIVE, + true, + WebSocketConnectionBudget.standard(), + 1000))); + + assertThatThrownBy( + () -> + production.validate( + mixed, + messages(), + MessageAuthorizationPolicy.of(Map.of(PLACE, Set.of())), + origins(), + WebSocketAuthenticationProfile.ONE_TIME_TICKET, + WebSocketProtocolProfile.stable())) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("silently never answer"); + } + + @Test + @DisplayName("every problem is named at once") + void everyProblemIsNamedAtOnce() { + // Reporting the first sends an operator through one restart per problem, and each restart is a + // deploy. + assertThatThrownBy( + () -> + production.validate( + endpoints(false), + messages(), + MessageAuthorizationPolicy.of(Map.of()), + WebSocketOriginPolicy.browsersOnly(Set.of()), + WebSocketAuthenticationProfile.SESSION_COOKIE, + WebSocketProtocolProfile.localCompatibility())) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("the whole defence") + .hasMessageContaining("assume their own current format") + .hasMessageContaining("holding server memory for hours") + .hasMessageContaining("unsendable"); + } +} diff --git a/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/config/WebSocketStackExclusivityTest.java b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/config/WebSocketStackExclusivityTest.java new file mode 100644 index 00000000..b6ecf9b5 --- /dev/null +++ b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/config/WebSocketStackExclusivityTest.java @@ -0,0 +1,73 @@ +package dev.caskeleton.adapter.inbound.websocket.config; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketStackProfile; +import java.time.Duration; +import java.util.Set; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * The mismatch that produces a healthy-looking deployment serving nothing. + * + *

Boot deduces one application type from the classpath, and with both present it picks the + * servlet one. A deployment that declared reactive endpoints and shipped both therefore starts, + * reports itself healthy, and never answers — no error and no warning anywhere. + */ +class WebSocketStackExclusivityTest { + + @Test + @DisplayName("the resolved stack is what the classpath will actually produce") + void resolvedStackMatchesTheClasspath() { + // This leaf compiles against both runtimes, and the test classpath carries the servlet one. + // The assertion is about agreeing with Boot's own deduction rather than about a preference. + assertThat(WebSocketStackExclusivity.resolvedStack()).contains(WebSocketStackProfile.SERVLET); + } + + @Test + @DisplayName("declaring the stack the classpath produces is accepted") + void matchingDeclarationIsAccepted() { + assertThatCode(() -> WebSocketStackExclusivity.require(WebSocketStackProfile.SERVLET)) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("declaring the other stack fails with an explanation") + void mismatchedDeclarationFails() { + // The whole value is in the message: "the WebSocket does not connect" is otherwise a support + // ticket with nothing to go on. + assertThatThrownBy(() -> WebSocketStackExclusivity.require(WebSocketStackProfile.REACTIVE)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("would never answer while"); + } + + @Test + @DisplayName("the platform defaults to serving nothing") + void platformDefaultsToDisabled() { + // A deployment that has not said which origins may connect has not configured a WebSocket + // endpoint, and starting one anyway would mean the platform chose its own CSRF posture. + WebSocketPlatformSettings defaults = + new WebSocketPlatformSettings( + false, + "local", + Set.of(), + false, + 65536, + 524288, + 16, + 1048576, + 67108864, + 4, + Duration.ofSeconds(15), + Duration.ofSeconds(45), + Duration.ofHours(4), + Duration.ofSeconds(30)); + + assertThat(defaults.enabled()).isFalse(); + assertThat(defaults.hasOriginAllowlist()).isFalse(); + assertThat(defaults.allowMissingOrigin()).isFalse(); + } +} diff --git a/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/core/WebSocketConnectionStateTest.java b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/core/WebSocketConnectionStateTest.java new file mode 100644 index 00000000..90ea769c --- /dev/null +++ b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/core/WebSocketConnectionStateTest.java @@ -0,0 +1,178 @@ +package dev.caskeleton.adapter.inbound.websocket.core; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; +import java.time.Instant; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * The state machine, and the two things a long-lived connection gets wrong that a request cannot. + * + *

A request is authenticated and finished. A connection is authenticated once and then runs for + * hours: it outlives the token, the revoked session and the role change, and it is still open + * during the deploy that was supposed to replace it. Both of those have their own case here. + */ +class WebSocketConnectionStateTest { + + private static final Instant NOW = Instant.parse("2026-08-25T10:00:00Z"); + private static final byte[] SALT = + "a-deployment-salt-of-adequate-length".getBytes(java.nio.charset.StandardCharsets.UTF_8); + + @Test + @DisplayName("a connection walks forward through its states") + void statesMoveForward() { + assertThat(WebSocketConnectionState.CONNECTING.canTransitionTo(WebSocketConnectionState.OPEN)) + .isTrue(); + assertThat(WebSocketConnectionState.OPEN.canTransitionTo(WebSocketConnectionState.DRAINING)) + .isTrue(); + assertThat(WebSocketConnectionState.DRAINING.canTransitionTo(WebSocketConnectionState.CLOSING)) + .isTrue(); + assertThat(WebSocketConnectionState.CLOSING.canTransitionTo(WebSocketConnectionState.CLOSED)) + .isTrue(); + } + + @Test + @DisplayName("a draining connection cannot go back to open") + void drainingIsOneWay() { + // The one edge a rolling restart depends on being absent. A node that announced it was leaving + // must not resume taking work. + assertThat(WebSocketConnectionState.DRAINING.canTransitionTo(WebSocketConnectionState.OPEN)) + .isFalse(); + assertThat(WebSocketConnectionState.CLOSED.canTransitionTo(WebSocketConnectionState.OPEN)) + .isFalse(); + } + + @Test + @DisplayName("a draining connection still finishes its in-flight writes") + void drainingIsWritableButRefusesNewInbound() { + // Both halves matter. Refusing writes cuts a message in half; accepting inbound keeps a + // departing node taking work it will not finish. + assertThat(WebSocketConnectionState.DRAINING.writable()).isTrue(); + assertThat(WebSocketConnectionState.DRAINING.acceptsInbound()).isFalse(); + } + + @Test + @DisplayName("a connecting connection is not yet writable") + void connectingIsNotWritable() { + assertThat(WebSocketConnectionState.CONNECTING.writable()).isFalse(); + assertThat(WebSocketConnectionState.CLOSING.writable()).isFalse(); + assertThat(WebSocketConnectionState.CLOSED.writable()).isFalse(); + } + + @Test + @DisplayName("a context refuses a transition the machine does not allow") + void contextRefusesAnIllegalTransition() { + WebSocketConnectionContext open = context(WebSocketConnectionState.OPEN); + + assertThatCode(() -> open.transitionTo(WebSocketConnectionState.DRAINING)) + .doesNotThrowAnyException(); + assertThatThrownBy( + () -> + open.transitionTo(WebSocketConnectionState.DRAINING) + .transitionTo(WebSocketConnectionState.OPEN)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("moves forward"); + } + + @Test + @DisplayName("identity does not change with state") + void identityIsStableAcrossTransitions() { + // An actor that could change mid-life would make every authorization decision conditional on + // when it was asked, and the audit record would name the last actor rather than the one who + // acted. + WebSocketConnectionContext open = context(WebSocketConnectionState.OPEN); + + WebSocketConnectionContext draining = open.transitionTo(WebSocketConnectionState.DRAINING); + + assertThat(draining.actor()).isEqualTo(open.actor()); + assertThat(draining.endpoint()).isEqualTo(open.endpoint()); + assertThat(draining.subprotocol()).isEqualTo(open.subprotocol()); + assertThat(draining.connectionId()).isEqualTo(open.connectionId()); + } + + @Test + @DisplayName("the context holds a fingerprint, never the subject") + void contextHoldsNoRawIdentity() { + WebSocketActorReference actor = WebSocketActorReference.of("alice@example.com", "acme", SALT); + + assertThat(actor.fingerprint()).doesNotContain("alice"); + assertThat(actor.fingerprint()).doesNotContain("acme"); + assertThat(actor.fingerprint()).hasSize(64); + assertThat(actor.shortForm()).hasSize(12); + } + + @Test + @DisplayName("the same identity produces the same reference, a different one does not") + void referenceIsStableAndDiscriminating() { + // Both properties are needed: stable so a reconnect is recognisable, discriminating so two + // actors are never conflated in an admin listing. + assertThat(WebSocketActorReference.of("alice", "acme", SALT)) + .isEqualTo(WebSocketActorReference.of("alice", "acme", SALT)); + assertThat(WebSocketActorReference.of("alice", "acme", SALT)) + .isNotEqualTo(WebSocketActorReference.of("alice", "other", SALT)); + assertThat(WebSocketActorReference.of("alice", "acme", SALT)) + .isNotEqualTo(WebSocketActorReference.of("bob", "acme", SALT)); + } + + @Test + @DisplayName("an unsalted reference is refused") + void referenceRequiresRealSalt() { + // Subjects come from small guessable sets. An unsalted digest of one is a rainbow table away + // from the subject it was meant to hide. + assertThatThrownBy(() -> WebSocketActorReference.of("alice", "acme", new byte[4])) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("guessable sets"); + } + + @Test + @DisplayName("a credential expiry closes the connection after its grace period") + void credentialExpiryClosesTheConnection() { + WebSocketCredentialExpiry expiry = + WebSocketCredentialExpiry.at(NOW.plusSeconds(60), Duration.ofSeconds(30)); + + assertThat(expiry.expiredAt(NOW)).isFalse(); + assertThat(expiry.expiredAt(NOW.plusSeconds(61))).isTrue(); + // Expired but still inside the grace: the client has a window to re-authenticate. + assertThat(expiry.mustCloseAt(NOW.plusSeconds(61))).isFalse(); + assertThat(expiry.mustCloseAt(NOW.plusSeconds(91))).isTrue(); + } + + @Test + @DisplayName("a grace period long enough to make expiry advisory is refused") + void longGraceIsRefused() { + assertThatThrownBy(() -> WebSocketCredentialExpiry.at(NOW, Duration.ofHours(1))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("advisory"); + } + + @Test + @DisplayName("maximum connection age is separate from credential expiry") + void maximumAgeIsSeparateFromExpiry() { + // A never-expiring service credential still should not hold one connection open across three + // deploys. Age is what forces a client back through the handshake, where current policy is + // applied. + WebSocketConnectionContext open = context(WebSocketConnectionState.OPEN); + + assertThat(open.credentialExpiry().mustCloseAt(NOW.plusSeconds(100_000))).isFalse(); + assertThat(open.olderThan(Duration.ofHours(4), NOW.plusSeconds(100_000))).isTrue(); + assertThat(open.olderThan(Duration.ofHours(4), NOW.plusSeconds(60))).isFalse(); + } + + private static WebSocketConnectionContext context(WebSocketConnectionState state) { + return new WebSocketConnectionContext( + new WebSocketConnectionId("c-01H8XQ2N4K"), + new WebSocketSessionId("s-01H8XQ2N4K"), + new WebSocketNodeId("edge-1"), + new WebSocketEndpointName("live-updates"), + Optional.of(WebSocketSubprotocolName.stable()), + WebSocketActorReference.of("alice", "acme", SALT), + state, + WebSocketCredentialExpiry.never(), + NOW); + } +} diff --git a/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/core/WebSocketEndpointProfileTest.java b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/core/WebSocketEndpointProfileTest.java new file mode 100644 index 00000000..7dc99509 --- /dev/null +++ b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/core/WebSocketEndpointProfileTest.java @@ -0,0 +1,152 @@ +package dev.caskeleton.adapter.inbound.websocket.core; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.inbound.websocket.budget.WebSocketConnectionBudget; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * What a declared endpoint must say before it can be served. + * + *

An endpoint is the only thing here reachable from the internet, and a WebSocket endpoint is + * reachable for hours at a time. Each case is something a deployment could leave unsaid and only + * discover under load or under attack. + */ +class WebSocketEndpointProfileTest { + + @Test + @DisplayName("an endpoint path must be canonical and absolute") + void pathMustBeCanonical() { + assertThatCode(() -> profile("live-updates", "/ws/v1/live")).doesNotThrowAnyException(); + // A trailing slash, a dot segment or a relative path each make the route that answers differ + // from the route that was reviewed. + assertThatThrownBy(() -> profile("live-updates", "/ws/v1/live/")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> profile("live-updates", "ws/v1/live")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> profile("live-updates", "/ws/../admin")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("every endpoint declares a positive connection cap") + void connectionCapIsMandatory() { + assertThatThrownBy( + () -> + new WebSocketEndpointProfile( + new WebSocketEndpointName("live-updates"), + "/ws/v1/live", + WebSocketStackProfile.SERVLET, + true, + WebSocketConnectionBudget.standard(), + 0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("connects the most"); + } + + @Test + @DisplayName("an unauthenticated endpoint is buildable but not production safe") + void unauthenticatedEndpointIsFlagged() { + // Not refused: a public status feed is a real thing. Flagged, so serving one in production is + // a decision somebody made rather than a default nobody noticed. + WebSocketEndpointProfile open = + new WebSocketEndpointProfile( + new WebSocketEndpointName("public-status"), + "/ws/v1/status", + WebSocketStackProfile.SERVLET, + false, + WebSocketConnectionBudget.standard(), + 100); + + assertThat(open.productionSafe()).isFalse(); + assertThat(WebSocketEndpointCatalog.of(List.of(open)).productionUnsafe()).containsExactly(open); + } + + @Test + @DisplayName("two endpoints on one path are refused") + void duplicatePathIsRefused() { + // Which one answers would be decided by registration order, and the loser is silently + // unreachable — indistinguishable from a client bug. + assertThatThrownBy( + () -> + WebSocketEndpointCatalog.of( + List.of( + profile("live-updates", "/ws/v1/live"), + profile("other-feed", "/ws/v1/live")))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("silently unreachable"); + } + + @Test + @DisplayName("two endpoints with one name are refused") + void duplicateNameIsRefused() { + assertThatThrownBy( + () -> + WebSocketEndpointCatalog.of( + List.of( + profile("live-updates", "/ws/v1/live"), + profile("live-updates", "/ws/v1/other")))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("the catalog resolves by path and by name") + void catalogResolvesBothWays() { + WebSocketEndpointCatalog catalog = + WebSocketEndpointCatalog.of(List.of(profile("live-updates", "/ws/v1/live"))); + + assertThat(catalog.findByPath("/ws/v1/live")).isPresent(); + assertThat(catalog.findByPath("/ws/v1/live/")).isEmpty(); + assertThat(catalog.require(new WebSocketEndpointName("live-updates")).path()) + .isEqualTo("/ws/v1/live"); + } + + @Test + @DisplayName("an undeclared endpoint is refused rather than defaulted") + void undeclaredEndpointIsRefused() { + WebSocketEndpointCatalog catalog = + WebSocketEndpointCatalog.of(List.of(profile("live-updates", "/ws/v1/live"))); + + assertThatThrownBy(() -> catalog.require(new WebSocketEndpointName("nope"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("declared set"); + } + + @Test + @DisplayName("a catalog spanning both runtimes is detectable") + void mixedStackCatalogIsDetectable() { + // Boot deduces one application type from the classpath, so a catalog spanning both means one + // set of endpoints is never served and nothing says so. + WebSocketEndpointCatalog mixed = + WebSocketEndpointCatalog.of( + List.of( + profile("live-updates", "/ws/v1/live"), + new WebSocketEndpointProfile( + new WebSocketEndpointName("reactive-feed"), + "/ws/v1/reactive", + WebSocketStackProfile.REACTIVE, + true, + WebSocketConnectionBudget.standard(), + 100))); + + assertThat(mixed.singleStack()).isFalse(); + assertThat( + WebSocketEndpointCatalog.of(List.of(profile("live-updates", "/ws/v1/live"))) + .singleStack()) + .isTrue(); + } + + private static WebSocketEndpointProfile profile(String name, String path) { + return new WebSocketEndpointProfile( + new WebSocketEndpointName(name), + path, + WebSocketStackProfile.SERVLET, + true, + WebSocketConnectionBudget.standard(), + 1000); + } +} diff --git a/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/core/WebSocketIdentifierTest.java b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/core/WebSocketIdentifierTest.java new file mode 100644 index 00000000..7be8bd53 --- /dev/null +++ b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/core/WebSocketIdentifierTest.java @@ -0,0 +1,82 @@ +package dev.caskeleton.adapter.inbound.websocket.core; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Identifiers that travel further than anyone intends. + * + *

A connection identifier reaches logs, admin listings and whatever a client is handed for + * support. Every case here is a value that would be accepted by a plain {@code String} and cause + * trouble later: unbounded length, a control character in a log line, or a guessable value. + */ +class WebSocketIdentifierTest { + + @Test + @DisplayName("an endpoint name is lowercase, bounded and dash-separated") + void endpointNameIsBounded() { + assertThatCode(() -> new WebSocketEndpointName("live-updates")).doesNotThrowAnyException(); + assertThatThrownBy(() -> new WebSocketEndpointName("Live-Updates")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new WebSocketEndpointName("a")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new WebSocketEndpointName("x".repeat(65))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("an endpoint name cannot carry whitespace or control characters") + void endpointNameRejectsControlCharacters() { + // A newline here is a forged log line everywhere the name is logged. + assertThatThrownBy(() -> new WebSocketEndpointName("live updates")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new WebSocketEndpointName("live\nupdates")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("a connection id is long enough not to be guessed and short enough to be bounded") + void connectionIdIsBounded() { + assertThatCode(() -> new WebSocketConnectionId("c-01H8XQ2N4K")).doesNotThrowAnyException(); + assertThatThrownBy(() -> new WebSocketConnectionId("short")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new WebSocketConnectionId("x".repeat(65))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("an identifier that looks like a credential or a key is still refused by shape") + void identifiersRejectStructuredValues() { + // Not proof that a caller did not encode meaning, but it does refuse the shapes that carry it: + // a JWT has dots, a URL has slashes, an email has an at sign. + assertThatThrownBy(() -> new WebSocketConnectionId("eyJhbGciOi.eyJzdWIi.sig")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new WebSocketSessionId("tenant/acme/user/42")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new WebSocketSessionId("alice@example.com")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("a node id is low cardinality, which is what makes it the one safe tag") + void nodeIdIsLowCardinality() { + assertThatCode(() -> new WebSocketNodeId("edge-3.eu-west-1")).doesNotThrowAnyException(); + assertThatThrownBy(() -> new WebSocketNodeId("Edge-3")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("connection and session identity are separate types") + void connectionAndSessionAreDistinctTypes() { + // Distinct so a reconnect can be recognised without reusing a transport identity, and so no + // method can take one where it meant the other. + WebSocketConnectionId connection = new WebSocketConnectionId("c-01H8XQ2N4K"); + WebSocketSessionId session = new WebSocketSessionId("c-01H8XQ2N4K"); + + assertThat((Object) connection).isNotEqualTo(session); + } +} diff --git a/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/error/WebSocketCloseCodeTest.java b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/error/WebSocketCloseCodeTest.java new file mode 100644 index 00000000..65d88c96 --- /dev/null +++ b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/error/WebSocketCloseCodeTest.java @@ -0,0 +1,152 @@ +package dev.caskeleton.adapter.inbound.websocket.error; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * How a failure reaches a peer, which is where WebSocket diverges from HTTP most sharply. + * + *

HTTP has one answer for everything: a status and a body. A WebSocket has three, chosen by + * whether the handshake completed and whether the connection survives — and picking the wrong one + * produces a client that sees a socket open and immediately close with nothing to explain it. + */ +class WebSocketCloseCodeTest { + + @Test + @DisplayName("before the handshake a failure is an HTTP problem") + void beforeHandshakeIsHttp() { + // There is no WebSocket yet. A close frame here is a frame on a connection that was never + // upgraded. + assertThat(WebSocketClosePolicy.transportFor(WebSocketFailureCategory.NOT_AUTHORIZED, false)) + .isEqualTo(WebSocketErrorTransport.HTTP_PROBLEM); + assertThat(WebSocketClosePolicy.transportFor(WebSocketFailureCategory.OVERLOADED, false)) + .isEqualTo(WebSocketErrorTransport.HTTP_PROBLEM); + } + + @Test + @DisplayName("after the handshake a survivable failure is a typed message") + void afterHandshakeSurvivableIsATypedMessage() { + // No status line left to send, and no reason to end a healthy connection over one bad message. + assertThat(WebSocketClosePolicy.transportFor(WebSocketFailureCategory.MALFORMED, true)) + .isEqualTo(WebSocketErrorTransport.TYPED_ERROR_MESSAGE); + assertThat(WebSocketClosePolicy.transportFor(WebSocketFailureCategory.VALIDATION_FAILED, true)) + .isEqualTo(WebSocketErrorTransport.TYPED_ERROR_MESSAGE); + } + + @Test + @DisplayName("a malformed message does not close the connection") + void malformedDoesNotDisconnect() { + // Disconnecting turns a client-side typo into a reconnect storm: the client reconnects and + // sends the same bad message. + assertThat(WebSocketClosePolicy.closeCodeFor(WebSocketFailureCategory.MALFORMED)).isEmpty(); + assertThat(WebSocketClosePolicy.closeCodeFor(WebSocketFailureCategory.UNKNOWN_TYPE)).isEmpty(); + assertThat(WebSocketClosePolicy.closeCodeFor(WebSocketFailureCategory.EXPIRED)).isEmpty(); + } + + @Test + @DisplayName("an expired credential does close the connection") + void expiredCredentialDisconnects() { + // Answering and continuing would leave an unauthorised peer subscribed and receiving data. + assertThat(WebSocketClosePolicy.closeCodeFor(WebSocketFailureCategory.CREDENTIAL_EXPIRED)) + .contains(WebSocketCloseCode.CREDENTIAL_EXPIRED); + assertThat(WebSocketClosePolicy.transportFor(WebSocketFailureCategory.CREDENTIAL_EXPIRED, true)) + .isEqualTo(WebSocketErrorTransport.CLOSE_FRAME); + } + + @Test + @DisplayName("message too big is 1009, the standard code") + void messageTooBigUsesTheStandardCode() { + // A browser surfaces 1009 with its own wording, so a developer learns what happened without + // the client library knowing anything about this API. + assertThat(WebSocketClosePolicy.closeCodeFor(WebSocketFailureCategory.TOO_LARGE)) + .contains(WebSocketCloseCode.MESSAGE_TOO_BIG); + assertThat(WebSocketCloseCode.MESSAGE_TOO_BIG.code()).isEqualTo(1009); + assertThat(WebSocketCloseCode.MESSAGE_TOO_BIG.applicationRange()).isFalse(); + } + + @Test + @DisplayName("temporary overload is 1013, with 4503 available alongside it") + void overloadUsesTheStandardCodeFirst() { + // 1013 is what a generic client backs off on. 4503 exists for this platform's own client to + // distinguish shedding from a transient server condition. + assertThat(WebSocketClosePolicy.closeCodeFor(WebSocketFailureCategory.OVERLOADED)) + .contains(WebSocketCloseCode.TRY_AGAIN_LATER); + assertThat(WebSocketCloseCode.TRY_AGAIN_LATER.code()).isEqualTo(1013); + assertThat(WebSocketCloseCode.SHED_FOR_CAPACITY.code()).isEqualTo(4503); + assertThat(WebSocketCloseCode.SHED_FOR_CAPACITY.applicationRange()).isTrue(); + } + + @Test + @DisplayName("codes a peer only reports can never be sent") + void reportOnlyCodesAreNotSendable() { + // 1005 and 1006 are what a browser reports when no close frame arrived. Sending them is a + // contradiction, and it is refused here rather than at the transport layer. + assertThat(WebSocketCloseCode.sendable(1005)).isFalse(); + assertThat(WebSocketCloseCode.sendable(1006)).isFalse(); + assertThat(WebSocketCloseCode.sendable(1015)).isFalse(); + assertThat(WebSocketCloseCode.sendable(1000)).isTrue(); + assertThat(WebSocketCloseCode.sendable(4503)).isTrue(); + } + + @Test + @DisplayName("every code this platform sends is sendable") + void everyDeclaredCodeIsSendable() { + for (WebSocketCloseCode code : WebSocketCloseCode.values()) { + assertThat(WebSocketCloseCode.sendable(code.code())) + .as("%s (%d) cannot appear in a close frame", code, code.code()) + .isTrue(); + } + } + + @Test + @DisplayName("an unsolicited error carries no correlation") + void unsolicitedErrorHasNoCorrelation() { + // The absence is the signal: a client with several requests in flight uses it to decide + // between failing one call and surfacing a connection-level problem. + assertThat( + WebSocketErrorMessage.unsolicited( + WebSocketFailureCategory.CREDENTIAL_EXPIRED, "the credential expired") + .correlationId()) + .isEmpty(); + assertThat( + WebSocketErrorMessage.answering( + WebSocketFailureCategory.VALIDATION_FAILED, "amount must be positive", "m-1") + .correlationId()) + .contains("m-1"); + } + + @Test + @DisplayName("a retry hint on a permanent failure is refused") + void retryHintOnPermanentFailureIsRefused() { + // It would invite a loop that can only end the same way. + assertThatThrownBy( + () -> + WebSocketErrorMessage.retryable( + WebSocketFailureCategory.VALIDATION_FAILED, "amount must be positive", 1000)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("cannot succeed"); + } + + @Test + @DisplayName("an error with no detail is refused") + void errorNeedsDetail() { + assertThatThrownBy( + () -> WebSocketErrorMessage.unsolicited(WebSocketFailureCategory.INTERNAL, " ")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("every failure category has a decided transport") + void everyCategoryIsDecided() { + // A category added without a decision here would fall through to whatever the switch's default + // happened to be; there is no default, so it cannot compile. + for (WebSocketFailureCategory category : WebSocketFailureCategory.values()) { + assertThat(WebSocketClosePolicy.transportFor(category, true)).isNotNull(); + assertThat(WebSocketClosePolicy.transportFor(category, false)) + .isEqualTo(WebSocketErrorTransport.HTTP_PROBLEM); + } + } +} diff --git a/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/evidence/WebSocketInboundEvidenceTest.java b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/evidence/WebSocketInboundEvidenceTest.java new file mode 100644 index 00000000..0d76b1d3 --- /dev/null +++ b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/evidence/WebSocketInboundEvidenceTest.java @@ -0,0 +1,201 @@ +package dev.caskeleton.adapter.inbound.websocket.evidence; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketConnectionId; +import dev.caskeleton.adapter.inbound.websocket.error.WebSocketCloseCode; +import dev.caskeleton.adapter.inbound.websocket.protocol.WebSocketMessageId; +import java.time.Instant; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * The question evidence exists to answer: after this connection died, was the work durable? + * + *

Nothing else can answer it. The connection is gone, the client is reconnecting, and the + * platform has to decide whether replaying the command is recovery or duplication. Every rule here + * exists so the answer is what happened rather than what was last written. + */ +class WebSocketInboundEvidenceTest { + + private static final Instant NOW = Instant.parse("2026-08-25T10:00:00Z"); + private static final WebSocketMessageId MESSAGE = new WebSocketMessageId("m-1"); + + @Test + @DisplayName("evidence starts empty and is not durable") + void startsUnknownAndNotDurable() { + WebSocketInboundEvidence evidence = new WebSocketInboundEvidence(MESSAGE); + + assertThat(evidence.furthestStage()).isEmpty(); + assertThat(evidence.durable()).isFalse(); + assertThat(evidence.safeToRetry()).isTrue(); + } + + @Test + @DisplayName("only the application may claim its work committed") + void onlyTheApplicationClaimsCommit() { + // The rule the whole record exists for. A handler returning means the method returned; it does + // not mean a transaction committed, and inferring one from the other is how a retry + // re-executes a committed write. + assertThatThrownBy( + () -> + new WebSocketInboundEvidence(MESSAGE) + .record( + WebSocketInboundStage.APPLICATION_COMMITTED, + WebSocketEvidenceSource.PLATFORM, + NOW)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("not the same thing"); + + assertThatCode( + () -> + new WebSocketInboundEvidence(MESSAGE) + .record( + WebSocketInboundStage.APPLICATION_COMMITTED, + WebSocketEvidenceSource.APPLICATION, + NOW)) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("started is not committed") + void startedIsNotCommitted() { + // One step apart, opposite meanings when the connection dies between them. + WebSocketInboundEvidence started = + new WebSocketInboundEvidence(MESSAGE) + .record( + WebSocketInboundStage.APPLICATION_STARTED, WebSocketEvidenceSource.PLATFORM, NOW); + + assertThat(started.durable()).isFalse(); + assertThat(started.safeToRetry()).isTrue(); + + WebSocketInboundEvidence committed = + started.record( + WebSocketInboundStage.APPLICATION_COMMITTED, + WebSocketEvidenceSource.APPLICATION, + NOW.plusMillis(5)); + + assertThat(committed.durable()).isTrue(); + assertThat(committed.safeToRetry()).isFalse(); + } + + @Test + @DisplayName("evidence cannot move backwards") + void evidenceIsMonotonic() { + // Evidence that can go backwards answers "was this durable" with whatever was written last. + WebSocketInboundEvidence evidence = + new WebSocketInboundEvidence(MESSAGE) + .record( + WebSocketInboundStage.APPLICATION_COMMITTED, + WebSocketEvidenceSource.APPLICATION, + NOW); + + assertThatThrownBy( + () -> + evidence.record( + WebSocketInboundStage.ADMITTED, WebSocketEvidenceSource.PLATFORM, NOW)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("backwards"); + } + + @Test + @DisplayName("the same stage cannot be recorded twice") + void sameStageCannotRepeat() { + WebSocketInboundEvidence evidence = + new WebSocketInboundEvidence(MESSAGE) + .record(WebSocketInboundStage.ADMITTED, WebSocketEvidenceSource.PLATFORM, NOW); + + assertThatThrownBy( + () -> + evidence.record( + WebSocketInboundStage.ADMITTED, WebSocketEvidenceSource.PLATFORM, NOW)) + .isInstanceOf(IllegalStateException.class); + } + + @Test + @DisplayName("stage order is declared, not taken from declaration position") + void stageOrderIsDeclared() { + // ordinal() would renumber every stage after an insertion, and comparisons that were correct + // become wrong with no compile error. + assertThat(WebSocketInboundStage.FRAME_RECEIVED.rank()) + .isLessThan(WebSocketInboundStage.APPLICATION_COMMITTED.rank()); + assertThat(WebSocketInboundStage.APPLICATION_STARTED.durable()).isFalse(); + assertThat(WebSocketInboundStage.APPLICATION_COMMITTED.durable()).isTrue(); + assertThat(WebSocketInboundStage.RESPONSE_ENQUEUED.durable()).isTrue(); + } + + @Test + @DisplayName("a flush is not a delivery") + void flushIsNotDelivery() { + // A WebSocket gives the sender no application acknowledgement, so delivery is not observable + // here. Treating a flush as one turns at-least-once into at-most-once during a partition. + WebSocketOutboundEvidence flushed = + WebSocketOutboundEvidence.queued(MESSAGE, NOW).written().flushed(NOW.plusMillis(2)); + + assertThat(flushed.stage()).isEqualTo(WebSocketOutboundStage.FLUSHED); + assertThat(flushed.knownReceived()).isFalse(); + assertThat(flushed.acknowledgedAt(NOW.plusMillis(9)).knownReceived()).isTrue(); + } + + @Test + @DisplayName("there is no DELIVERED stage to reach for") + void thereIsNoDeliveredStage() { + assertThat(WebSocketOutboundStage.values()) + .noneMatch(stage -> stage.name().contains("DELIVERED")); + } + + @Test + @DisplayName("an acknowledgement for something never sent is refused") + void acknowledgementWithoutASendIsRefused() { + assertThatThrownBy( + () -> + new WebSocketOutboundEvidence( + MESSAGE, + WebSocketOutboundStage.QUEUED, + NOW, + Optional.empty(), + Optional.of(NOW))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("never sent"); + } + + @Test + @DisplayName("a dropped outbound message is recorded, not silent") + void droppedMessagesAreVisible() { + // A clean close hides this entirely. Two hours and a normal close code looks identical whether + // four thousand messages were discarded or none were. + WebSocketConnectionEvidence lossy = + new WebSocketConnectionEvidence( + new WebSocketConnectionId("c-01H8XQ2N4K"), + NOW, + Optional.of(NOW.plusSeconds(7200)), + Optional.of(WebSocketCloseCode.NORMAL), + 120, + 4000, + 4000, + 900_000); + + assertThat(lossy.lostMessages()).isTrue(); + assertThat(lossy.lifetime(NOW)).isEqualTo(java.time.Duration.ofHours(2)); + } + + @Test + @DisplayName("a close code without a close time is refused") + void closeCodeNeedsACloseTime() { + assertThatThrownBy( + () -> + new WebSocketConnectionEvidence( + new WebSocketConnectionId("c-01H8XQ2N4K"), + NOW, + Optional.empty(), + Optional.of(WebSocketCloseCode.NORMAL), + 0, + 0, + 0, + 0)) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/fault/CommandLossRecoveryTest.java b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/fault/CommandLossRecoveryTest.java new file mode 100644 index 00000000..877c440e --- /dev/null +++ b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/fault/CommandLossRecoveryTest.java @@ -0,0 +1,236 @@ +package dev.caskeleton.adapter.inbound.websocket.fault; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketActorReference; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketEndpointName; +import dev.caskeleton.adapter.inbound.websocket.idempotency.CommandReconciliation; +import dev.caskeleton.adapter.inbound.websocket.idempotency.CommittedResultLedger; +import dev.caskeleton.adapter.inbound.websocket.idempotency.WebSocketCommandKey; +import dev.caskeleton.adapter.inbound.websocket.idempotency.WebSocketCommandOutcome; +import dev.caskeleton.adapter.inbound.websocket.protocol.WebSocketMessageId; +import dev.caskeleton.adapter.inbound.websocket.testkit.fault.WebSocketFaultInjector; +import dev.caskeleton.adapter.inbound.websocket.testkit.fault.WebSocketFaultPoint; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * A crash at each point in a command's life, and what a reconnecting client must then be told. + * + *

The reconnect is what makes this a WebSocket problem rather than a general one. An HTTP client + * that loses a response has one request to reason about; a WebSocket client reconnects and replays + * everything it never saw an answer for, all at once — so the recovery path is exercised at + * concurrency, on every deploy, whether or not anybody planned for it. + */ +class CommandLossRecoveryTest { + + private static final Instant NOW = Instant.parse("2026-08-25T10:00:00Z"); + private static final Duration LEASE = Duration.ofSeconds(30); + private static final byte[] SALT = + "a-deployment-salt-of-adequate-length".getBytes(StandardCharsets.UTF_8); + + private final Ledger ledger = new Ledger(); + private final WebSocketFaultInjector faults = new WebSocketFaultInjector(); + private final AtomicInteger sideEffects = new AtomicInteger(); + + private static WebSocketCommandKey key(String messageId) { + return new WebSocketCommandKey( + new WebSocketEndpointName("live-updates"), + WebSocketActorReference.of("alice", "acme", SALT), + new WebSocketMessageId(messageId)); + } + + /** + * One attempt at handling a command, with faults where the platform's own steps are. + * + * @return the result to send, or empty when the attempt did not get that far + */ + private Optional attempt(WebSocketCommandKey commandKey, Instant now) { + faults.trigger(WebSocketFaultPoint.BEFORE_DECODE); + WebSocketCommandOutcome outcome = ledger.claim(commandKey, now, LEASE); + if (outcome == WebSocketCommandOutcome.COMMITTED) { + return ledger.committedResult(commandKey, now); + } + if (outcome != WebSocketCommandOutcome.NEW) { + return Optional.empty(); + } + faults.trigger(WebSocketFaultPoint.AFTER_ADMISSION_BEFORE_APPLICATION); + faults.trigger(WebSocketFaultPoint.AFTER_APPLICATION_START_BEFORE_COMMIT); + // The business work. + int order = sideEffects.incrementAndGet(); + faults.trigger(WebSocketFaultPoint.AFTER_APPLICATION_COMMIT_BEFORE_LEDGER); + ledger.recordCommitted(commandKey, "{\"order\":" + order + "}", now); + faults.trigger(WebSocketFaultPoint.AFTER_LEDGER_BEFORE_RESPONSE); + return ledger.committedResult(commandKey, now); + } + + @Test + @DisplayName("a crash before the application leaves nothing behind") + void crashBeforeApplicationLeavesNothing() { + faults.arm(WebSocketFaultPoint.AFTER_ADMISSION_BEFORE_APPLICATION); + expectFault(() -> attempt(key("m-1"), NOW)); + + // The claim stands, so the immediate retry is told to wait rather than running twice. After + // the lease lapses it is genuinely retryable, because nothing durable happened. + assertThat(sideEffects).hasValue(0); + assertThat(ledger.claim(key("m-1"), NOW.plus(LEASE).plusSeconds(1), LEASE)) + .isEqualTo(WebSocketCommandOutcome.UNKNOWN); + } + + @Test + @DisplayName("a crash between commit and ledger is UNKNOWN, and reconciliation settles it") + void crashInTheWindowIsUnknownAndReconcilable() { + // The only window that cannot be resolved by replaying or re-running. + faults.arm(WebSocketFaultPoint.AFTER_APPLICATION_COMMIT_BEFORE_LEDGER); + expectFault(() -> attempt(key("m-1"), NOW)); + + assertThat(sideEffects).hasValue(1); + Instant later = NOW.plus(LEASE).plusSeconds(1); + assertThat(ledger.outcome(key("m-1"), later)).isEqualTo(WebSocketCommandOutcome.UNKNOWN); + + // Only the business data can say. Here it can: the side effect is visible. + CommandReconciliation reconciliation = + (commandKey, now) -> + sideEffects.get() > 0 + ? CommandReconciliation.Verdict.committed("{\"order\":1}") + : CommandReconciliation.Verdict.notCommitted(); + + assertThat(reconciliation.reconcile(key("m-1"), later).state()) + .isEqualTo(CommandReconciliation.State.COMMITTED); + // And the work was not done twice on the way to finding out. + assertThat(sideEffects).hasValue(1); + } + + @Test + @DisplayName("a crash after the ledger replays the stored result rather than re-running") + void crashAfterLedgerReplays() { + faults.arm(WebSocketFaultPoint.AFTER_LEDGER_BEFORE_RESPONSE); + expectFault(() -> attempt(key("m-1"), NOW)); + + Optional replayed = attempt(key("m-1"), NOW.plusSeconds(1)); + + assertThat(replayed).contains("{\"order\":1}"); + assertThat(sideEffects).hasValue(1); + } + + @Test + @DisplayName("a reconnect storm replaying one command executes it once") + void reconnectStormExecutesOnce() { + // The shape a WebSocket produces and HTTP does not: the same command arriving many times at + // once, because every client on a dropped node reconnects together. + List> results = new ArrayList<>(); + for (int index = 0; index < 20; index++) { + results.add(attempt(key("m-1"), NOW.plusMillis(index))); + } + + assertThat(sideEffects).hasValue(1); + assertThat(results).filteredOn(Optional::isPresent).isNotEmpty(); + assertThat(results.stream().filter(Optional::isPresent).map(Optional::orElseThrow).distinct()) + .containsExactly("{\"order\":1}"); + } + + @Test + @DisplayName("two different commands from one client are both executed") + void distinctCommandsBothRun() { + // The mirror of the above: deduplicating too aggressively is the other way to be wrong, and it + // is silent. + attempt(key("m-1"), NOW); + attempt(key("m-2"), NOW); + + assertThat(sideEffects).hasValue(2); + } + + private static void expectFault(Runnable attempt) { + try { + attempt.run(); + throw new AssertionError("the armed fault did not fire"); + } catch (WebSocketFaultInjector.InjectedWebSocketFault expected) { + // The point of arming it. + } + } + + /** A ledger with the semantics a real one must have. */ + private static final class Ledger implements CommittedResultLedger { + + private record Entry( + WebSocketCommandOutcome outcome, Optional result, Instant leaseExpiresAt) {} + + private final Map entries = new ConcurrentHashMap<>(); + + @Override + public WebSocketCommandOutcome claim( + WebSocketCommandKey key, Instant now, Duration leaseDuration) { + Objects.requireNonNull(key, "key"); + Entry claim = + new Entry(WebSocketCommandOutcome.IN_FLIGHT, Optional.empty(), now.plus(leaseDuration)); + Entry existing = entries.putIfAbsent(key.storageKey(), claim); + if (existing == null) { + return WebSocketCommandOutcome.NEW; + } + return switch (existing.outcome()) { + case COMMITTED -> WebSocketCommandOutcome.COMMITTED; + case FAILED -> + entries.replace(key.storageKey(), existing, claim) + ? WebSocketCommandOutcome.NEW + : WebSocketCommandOutcome.IN_FLIGHT; + case IN_FLIGHT -> + now.isBefore(existing.leaseExpiresAt()) + ? WebSocketCommandOutcome.IN_FLIGHT + : WebSocketCommandOutcome.UNKNOWN; + case NEW, UNKNOWN -> WebSocketCommandOutcome.UNKNOWN; + }; + } + + @Override + public void recordCommitted(WebSocketCommandKey key, String encodedResult, Instant at) { + entries.put( + key.storageKey(), + new Entry(WebSocketCommandOutcome.COMMITTED, Optional.of(encodedResult), at)); + } + + @Override + public void recordFailed(WebSocketCommandKey key, Instant at) { + entries.computeIfPresent( + key.storageKey(), + (storageKey, existing) -> + existing.outcome() == WebSocketCommandOutcome.COMMITTED + ? existing + : new Entry(WebSocketCommandOutcome.FAILED, Optional.empty(), at)); + } + + @Override + public Optional committedResult(WebSocketCommandKey key, Instant now) { + Entry entry = entries.get(key.storageKey()); + return entry != null && entry.outcome() == WebSocketCommandOutcome.COMMITTED + ? entry.result() + : Optional.empty(); + } + + @Override + public WebSocketCommandOutcome outcome(WebSocketCommandKey key, Instant now) { + Entry entry = entries.get(key.storageKey()); + if (entry == null) { + return WebSocketCommandOutcome.NEW; + } + return entry.outcome() == WebSocketCommandOutcome.IN_FLIGHT + && !now.isBefore(entry.leaseExpiresAt()) + ? WebSocketCommandOutcome.UNKNOWN + : entry.outcome(); + } + + @Override + public List unresolved(Instant now) { + return List.of(); + } + } +} diff --git a/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/handler/LateResponseTombstoneTest.java b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/handler/LateResponseTombstoneTest.java new file mode 100644 index 00000000..6a3f393a --- /dev/null +++ b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/handler/LateResponseTombstoneTest.java @@ -0,0 +1,115 @@ +package dev.caskeleton.adapter.inbound.websocket.handler; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketConnectionId; +import dev.caskeleton.adapter.inbound.websocket.protocol.WebSocketCorrelationId; +import java.time.Duration; +import java.time.Instant; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * What happens to the answer that arrives after the caller gave up. + * + *

The bug this prevents produces no error at any layer. A request times out, the work behind it + * finishes anyway, and the answer arrives at a registry where the correlation id has since been + * claimed by a different request — because clients that number their requests reuse ids as a matter + * of course. The answer is delivered, the shape matches, and the client applies somebody else's + * result. + */ +class LateResponseTombstoneTest { + + private static final Instant NOW = Instant.parse("2026-08-25T10:00:00Z"); + private static final WebSocketConnectionId CONNECTION = new WebSocketConnectionId("conn-0001"); + private static final WebSocketCorrelationId CORRELATION = new WebSocketCorrelationId("req-7"); + + private final LateResponseTombstone tombstones = + new LateResponseTombstone(Duration.ofSeconds(30)); + + @Test + @DisplayName("an id nobody timed out is not late") + void unknownIdIsNotLate() { + assertThat(tombstones.isLate(CONNECTION, CORRELATION, NOW)).isFalse(); + assertThat(tombstones.mayReuse(CONNECTION, CORRELATION, NOW)).isTrue(); + } + + @Test + @DisplayName("an answer inside the window is recognised as late") + void answerInsideTheWindowIsLate() { + tombstones.record(CONNECTION, CORRELATION, NOW); + + assertThat(tombstones.isLate(CONNECTION, CORRELATION, NOW.plusSeconds(29))).isTrue(); + } + + @Test + @DisplayName("the id cannot be reused while the tombstone stands") + void reuseIsRefusedWhileTombstoned() { + // The other half of the same defect. Recognising the late answer is useless if a new request + // has already taken the id it would have matched. + tombstones.record(CONNECTION, CORRELATION, NOW); + + assertThat(tombstones.mayReuse(CONNECTION, CORRELATION, NOW.plusSeconds(1))).isFalse(); + assertThat(tombstones.mayReuse(CONNECTION, CORRELATION, NOW.plusSeconds(30))).isTrue(); + } + + @Test + @DisplayName("the window ends, so the id becomes ordinary again") + void windowEnds() { + // Bounded on purpose: the alternative is a map that grows with every timeout the connection + // ever had, which turns a transient fault into a memory leak. + tombstones.record(CONNECTION, CORRELATION, NOW); + + assertThat(tombstones.isLate(CONNECTION, CORRELATION, NOW.plusSeconds(30))).isFalse(); + assertThat(tombstones.isLate(CONNECTION, CORRELATION, NOW.plusSeconds(31))).isFalse(); + } + + @Test + @DisplayName("a correlation id is scoped to its connection") + void correlationIsScopedToConnection() { + // Clients number from one. Without the connection in the key, the first client to time out + // request 1 would tombstone request 1 for every other client on the node. + tombstones.record(CONNECTION, CORRELATION, NOW); + + assertThat(tombstones.isLate(new WebSocketConnectionId("conn-0002"), CORRELATION, NOW)) + .isFalse(); + } + + @Test + @DisplayName("expired tombstones are evicted rather than accumulating") + void expiredTombstonesAreEvicted() { + tombstones.record(CONNECTION, CORRELATION, NOW); + tombstones.record(CONNECTION, new WebSocketCorrelationId("req-8"), NOW.plusSeconds(20)); + + assertThat(tombstones.evictExpired(NOW.plusSeconds(31))).isEqualTo(1); + assertThat(tombstones.size()).isEqualTo(1); + assertThat(tombstones.evictExpired(NOW.plusSeconds(51))).isEqualTo(1); + assertThat(tombstones.size()).isZero(); + } + + @Test + @DisplayName("a closed connection takes its tombstones with it") + void closedConnectionReleasesItsTombstones() { + // Nothing can arrive for a connection that is gone, and the ids cannot collide with a new + // connection's because the connection id is part of the key. + tombstones.record(CONNECTION, CORRELATION, NOW); + tombstones.record(CONNECTION, new WebSocketCorrelationId("req-8"), NOW); + tombstones.record(new WebSocketConnectionId("conn-0002"), CORRELATION, NOW); + + assertThat(tombstones.releaseConnection(CONNECTION)).isEqualTo(2); + assertThat(tombstones.size()).isEqualTo(1); + } + + @Test + @DisplayName("a zero window is refused at construction") + void zeroWindowIsRefused() { + // It would forget the id at the instant the late answer for it is most likely still in flight, + // which is a tombstone that exists and protects nothing. + assertThatThrownBy(() -> new LateResponseTombstone(Duration.ZERO)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("still in flight"); + assertThatThrownBy(() -> new LateResponseTombstone(Duration.ofSeconds(-1))) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/handler/WebSocketCorrelationRegistryTest.java b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/handler/WebSocketCorrelationRegistryTest.java new file mode 100644 index 00000000..ba7f88a9 --- /dev/null +++ b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/handler/WebSocketCorrelationRegistryTest.java @@ -0,0 +1,163 @@ +package dev.caskeleton.adapter.inbound.websocket.handler; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketConnectionId; +import dev.caskeleton.adapter.inbound.websocket.error.WebSocketFailureCategory; +import dev.caskeleton.adapter.inbound.websocket.protocol.WebSocketCorrelationId; +import dev.caskeleton.adapter.inbound.websocket.protocol.WebSocketMessageType; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Routing answers to waiters on a connection that multiplexes many at once. + * + *

The correlation id is chosen by the client, so two clients will eventually pick the same one. + * Most start their counters at 1, so "eventually" is the second connection. + */ +class WebSocketCorrelationRegistryTest { + + private static final Instant NOW = Instant.parse("2026-08-25T10:00:00Z"); + private static final Duration TTL = Duration.ofSeconds(30); + private static final WebSocketConnectionId ALICE = new WebSocketConnectionId("c-alice-0001"); + private static final WebSocketConnectionId BOB = new WebSocketConnectionId("c-bob-00001"); + private static final WebSocketCorrelationId ONE = new WebSocketCorrelationId("1"); + + private final WebSocketCorrelationRegistry registry = new WebSocketCorrelationRegistry(4); + + @Test + @DisplayName("an answer resolves the request that issued it") + void answerResolvesItsRequest() { + assertThat(registry.register(ALICE, ONE, NOW, TTL)).isTrue(); + + assertThat(registry.complete(ALICE, ONE, NOW.plusSeconds(1))).isPresent(); + // Taken, not copied: a second answer for the same correlation resolves nothing. + assertThat(registry.complete(ALICE, ONE, NOW.plusSeconds(1))).isEmpty(); + } + + @Test + @DisplayName("two connections may use the same correlation id without colliding") + void correlationIsScopedToTheConnection() { + // Clients pick these, and most start at 1. Keyed on the id alone, one caller's answer goes to + // the other — a disclosure rather than a mix-up. + assertThat(registry.register(ALICE, ONE, NOW, TTL)).isTrue(); + assertThat(registry.register(BOB, ONE, NOW, TTL)).isTrue(); + + assertThat(registry.complete(ALICE, ONE, NOW)).isPresent(); + // Bob's is untouched by Alice's answer. + assertThat(registry.complete(BOB, ONE, NOW)).isPresent(); + } + + @Test + @DisplayName("an answer on the wrong connection resolves nothing") + void answerOnAnotherConnectionResolvesNothing() { + registry.register(ALICE, ONE, NOW, TTL); + + assertThat(registry.complete(BOB, ONE, NOW)).isEmpty(); + assertThat(registry.complete(ALICE, ONE, NOW)).isPresent(); + } + + @Test + @DisplayName("a duplicate correlation on one connection is refused") + void duplicateCorrelationIsRefused() { + assertThat(registry.register(ALICE, ONE, NOW, TTL)).isTrue(); + assertThat(registry.register(ALICE, ONE, NOW, TTL)).isFalse(); + } + + @Test + @DisplayName("a connection cannot register more than its cap") + void inFlightIsCapped() { + // Without a cap a client registers faster than it consumes answers and the map is unbounded — + // the slow-consumer shape again, in a different structure. + for (int index = 0; index < 4; index++) { + assertThat(registry.register(ALICE, new WebSocketCorrelationId("c" + index), NOW, TTL)) + .isTrue(); + } + + assertThat(registry.register(ALICE, new WebSocketCorrelationId("c9"), NOW, TTL)).isFalse(); + } + + @Test + @DisplayName("an expired entry no longer resolves") + void expiredEntryDoesNotResolve() { + registry.register(ALICE, ONE, NOW, TTL); + + assertThat(registry.complete(ALICE, ONE, NOW.plus(TTL))).isEmpty(); + } + + @Test + @DisplayName("expired entries are returned so the waiter can be told") + void expiredEntriesAreReturned() { + // Silently discarding one leaves the client waiting for an answer that will never come. + registry.register(ALICE, ONE, NOW, TTL); + + List expired = registry.reapExpired(NOW.plus(TTL)); + + assertThat(expired).hasSize(1); + assertThat(expired.get(0).correlationId()).isEqualTo(ONE); + assertThat(registry.size()).isZero(); + } + + @Test + @DisplayName("expiry frees the connection's allowance") + void expiryFreesTheCap() { + for (int index = 0; index < 4; index++) { + registry.register(ALICE, new WebSocketCorrelationId("c" + index), NOW, TTL); + } + + assertThat(registry.countFor(ALICE, NOW.plus(TTL))).isZero(); + assertThat(registry.register(ALICE, new WebSocketCorrelationId("c9"), NOW.plus(TTL), TTL)) + .isTrue(); + } + + @Test + @DisplayName("closing a connection releases everything it owned") + void closingReleasesEverything() { + // Otherwise the leak is proportional to churn rather than to load. + registry.register(ALICE, ONE, NOW, TTL); + registry.register(BOB, ONE, NOW, TTL); + + assertThat(registry.releaseConnection(ALICE)).hasSize(1); + assertThat(registry.size()).isEqualTo(1); + assertThat(registry.complete(BOB, ONE, NOW)).isPresent(); + } + + @Test + @DisplayName("a handler cannot both fail and emit") + void handlerResultIsOneOrTheOther() { + // Both leaves the platform choosing what the client sees, and either choice is wrong for + // somebody. + assertThatThrownBy( + () -> + new WebSocketHandlerResult( + List.of( + new WebSocketHandlerResult.Emission( + new WebSocketMessageType("order.placed.v1"), "{}")), + java.util.Optional.of( + new WebSocketHandlerResult.HandlerFailure( + WebSocketFailureCategory.INTERNAL, "boom")))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("choosing what the"); + } + + @Test + @DisplayName("a handler describes what to send rather than sending it") + void handlerResultIsDescriptive() { + // The reason ordering and backpressure are guarantees rather than conventions: a handler has + // no way to reach past the outbound queue. + WebSocketHandlerResult result = + WebSocketHandlerResult.of(new WebSocketMessageType("order.placed.v1"), "{}"); + + assertThat(result.succeeded()).isTrue(); + assertThat(result.emissions()).hasSize(1); + assertThat(WebSocketHandlerResult.none().emissions()).isEmpty(); + assertThat( + WebSocketHandlerResult.failed(WebSocketFailureCategory.VALIDATION_FAILED, "bad") + .succeeded()) + .isFalse(); + } +} diff --git a/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/handshake/HandshakeAdmissionPipelineTest.java b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/handshake/HandshakeAdmissionPipelineTest.java new file mode 100644 index 00000000..3bb7ade1 --- /dev/null +++ b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/handshake/HandshakeAdmissionPipelineTest.java @@ -0,0 +1,352 @@ +package dev.caskeleton.adapter.inbound.websocket.handshake; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.inbound.websocket.budget.WebSocketConnectionBudget; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketActorReference; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketEndpointCatalog; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketEndpointName; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketEndpointProfile; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketStackProfile; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketSubprotocolName; +import dev.caskeleton.adapter.inbound.websocket.protocol.WebSocketProtocolProfile; +import dev.caskeleton.adapter.inbound.websocket.security.WebSocketAuthenticationProfile; +import dev.caskeleton.adapter.inbound.websocket.security.WebSocketConnectionTicket; +import dev.caskeleton.adapter.inbound.websocket.security.WebSocketOriginPolicy; +import dev.caskeleton.adapter.inbound.websocket.security.WebSocketTicketStore; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Handshake admission, where the order of the checks is itself the contract. + * + *

Everything here happens before the 101, which is the last point at which refusing is cheap and + * the only point at which the client can be told why in a way a browser surfaces. + */ +class HandshakeAdmissionPipelineTest { + + private static final Instant NOW = Instant.parse("2026-08-25T10:00:00Z"); + private static final byte[] SALT = + "a-deployment-salt-of-adequate-length".getBytes(StandardCharsets.UTF_8); + private static final WebSocketEndpointName ENDPOINT = new WebSocketEndpointName("live-updates"); + private static final String PATH = "/ws/v1/live"; + private static final WebSocketActorReference ALICE = + WebSocketActorReference.of("alice", "acme", SALT); + + private final AtomicInteger connections = new AtomicInteger(); + private final RecordingTicketStore tickets = new RecordingTicketStore(); + + private HandshakeAdmissionPipeline pipeline( + WebSocketAuthenticationProfile authentication, WebSocketOriginPolicy origins, int cap) { + return new HandshakeAdmissionPipeline( + WebSocketEndpointCatalog.of( + List.of( + new WebSocketEndpointProfile( + ENDPOINT, + PATH, + WebSocketStackProfile.SERVLET, + true, + WebSocketConnectionBudget.standard(), + cap))), + origins, + authentication, + WebSocketProtocolProfile.stable(), + tickets, + connections::get); + } + + private static HandshakeRequest request(String origin, String ticket) { + return new HandshakeRequest( + PATH, + Optional.ofNullable(origin), + List.of(WebSocketSubprotocolName.stable()), + Optional.ofNullable(ticket), + Optional.empty(), + false, + "203.0.113.4"); + } + + private String mintTicket() { + WebSocketConnectionTicket ticket = + new WebSocketConnectionTicket("t".repeat(40), ALICE, ENDPOINT, NOW, NOW.plusSeconds(10)); + tickets.issue(ticket); + return ticket.value(); + } + + @Test + @DisplayName("a valid handshake is admitted with the negotiated subprotocol") + void validHandshakeIsAdmitted() { + HandshakeDecision decision = + pipeline( + WebSocketAuthenticationProfile.ONE_TIME_TICKET, + WebSocketOriginPolicy.browsersOnly(Set.of("https://app.example.com")), + 100) + .admit(request("https://app.example.com", mintTicket()), NOW); + + assertThat(decision.admitted()).isTrue(); + assertThat(decision.actor()).contains(ALICE); + assertThat(decision.negotiatedSubprotocol()).contains(WebSocketSubprotocolName.stable()); + } + + @Test + @DisplayName("an unknown path is refused before anything else runs") + void unknownPathIsRefusedFirst() { + // A scanner sends exactly these. Doing origin or credential work first spends effort on a + // request that was never going to be served. + HandshakeDecision decision = + pipeline( + WebSocketAuthenticationProfile.ONE_TIME_TICKET, + WebSocketOriginPolicy.browsersOnly(Set.of("https://app.example.com")), + 100) + .admit( + new HandshakeRequest( + "/ws/v1/nope", + Optional.of("https://evil.example"), + List.of(WebSocketSubprotocolName.stable()), + Optional.of("t".repeat(40)), + Optional.empty(), + false, + "203.0.113.4"), + NOW); + + assertThat(decision.refusalStatus()).isEqualTo(404); + // The ticket was never looked at, which is the point of checking the route first. + assertThat(tickets.redemptions()).isZero(); + } + + @Test + @DisplayName("a foreign origin is refused before the credential is examined") + void foreignOriginIsRefusedBeforeAuthentication() { + // The order that makes the CSRF defence real. A cookie handshake from an attacker's page + // authenticates perfectly — the credential is valid; the page is not allowed to use it. + String ticket = mintTicket(); + + HandshakeDecision decision = + pipeline( + WebSocketAuthenticationProfile.ONE_TIME_TICKET, + WebSocketOriginPolicy.browsersOnly(Set.of("https://app.example.com")), + 100) + .admit(request("https://evil.example", ticket), NOW); + + assertThat(decision.refusalStatus()).isEqualTo(403); + assertThat(tickets.redemptions()).isZero(); + } + + @Test + @DisplayName("a saturated endpoint sheds before paying for the credential") + void saturationShedsBeforeAuthentication() { + connections.set(1); + String ticket = mintTicket(); + + HandshakeDecision decision = + pipeline( + WebSocketAuthenticationProfile.ONE_TIME_TICKET, + WebSocketOriginPolicy.browsersOnly(Set.of("https://app.example.com")), + 1) + .admit(request("https://app.example.com", ticket), NOW); + + assertThat(decision.refusalStatus()).isEqualTo(503); + // The expensive part was never reached, and the ticket is still spendable. + assertThat(tickets.redemptions()).isZero(); + } + + @Test + @DisplayName("a ticket works exactly once") + void ticketIsSingleUse() { + // Reconnect storms deliver concurrent duplicates, so a read-then-delete store would let two + // handshakes both find the same ticket. + String ticket = mintTicket(); + HandshakeAdmissionPipeline pipeline = + pipeline( + WebSocketAuthenticationProfile.ONE_TIME_TICKET, + WebSocketOriginPolicy.browsersOnly(Set.of("https://app.example.com")), + 100); + + assertThat(pipeline.admit(request("https://app.example.com", ticket), NOW).admitted()).isTrue(); + assertThat(pipeline.admit(request("https://app.example.com", ticket), NOW).refusalStatus()) + .isEqualTo(401); + } + + @Test + @DisplayName("a ticket minted for another endpoint does not open this one") + void ticketIsBoundToItsEndpoint() { + // Without the binding, a ticket for a low-privilege feed opens a privileged one. + WebSocketConnectionTicket elsewhere = + new WebSocketConnectionTicket( + "u".repeat(40), + ALICE, + new WebSocketEndpointName("other-feed"), + NOW, + NOW.plusSeconds(10)); + tickets.issue(elsewhere); + + HandshakeDecision decision = + pipeline( + WebSocketAuthenticationProfile.ONE_TIME_TICKET, + WebSocketOriginPolicy.browsersOnly(Set.of("https://app.example.com")), + 100) + .admit(request("https://app.example.com", elsewhere.value()), NOW); + + assertThat(decision.refusalStatus()).isEqualTo(401); + } + + @Test + @DisplayName("an expired ticket is refused") + void expiredTicketIsRefused() { + String ticket = mintTicket(); + + HandshakeDecision decision = + pipeline( + WebSocketAuthenticationProfile.ONE_TIME_TICKET, + WebSocketOriginPolicy.browsersOnly(Set.of("https://app.example.com")), + 100) + .admit(request("https://app.example.com", ticket), NOW.plusSeconds(60)); + + assertThat(decision.refusalStatus()).isEqualTo(401); + } + + @Test + @DisplayName("a ticket may not outlive its window") + void ticketLifetimeIsCapped() { + // The short lifetime is what makes a ticket in an access log harmless. Extend it and the + // query-string exposure it was introduced to fix comes back. + assertThatThrownBy( + () -> + new WebSocketConnectionTicket( + "t".repeat(40), ALICE, ENDPOINT, NOW, NOW.plus(Duration.ofHours(1)))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("bearer token in a query string"); + } + + @Test + @DisplayName("a short ticket is refused as guessable") + void shortTicketIsRefused() { + assertThatThrownBy( + () -> new WebSocketConnectionTicket("abc", ALICE, ENDPOINT, NOW, NOW.plusSeconds(10))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("guessable"); + } + + @Test + @DisplayName("a missing origin is refused for browsers and allowed for services") + void missingOriginDependsOnThePolicy() { + // Browsers always send one, so its absence means the caller is not the browser this endpoint + // is for. A service caller usually sends none, and refusing it would lock out every one. + assertThat(WebSocketOriginPolicy.browsersOnly(Set.of("https://app.example.com")).permits(null)) + .isFalse(); + assertThat( + WebSocketOriginPolicy.browsersAndServices(Set.of("https://app.example.com")) + .permits(null)) + .isTrue(); + } + + @Test + @DisplayName("a cookie profile with no origin allowlist is flagged unsafe") + void cookieProfileNeedsAnOriginAllowlist() { + // The same-origin policy does not protect a WebSocket handshake, and there is no preflight. + // The server's own Origin check is the entire defence. + assertThat( + WebSocketOriginPolicy.browsersOnly(Set.of()) + .safeFor(WebSocketAuthenticationProfile.SESSION_COOKIE)) + .isFalse(); + assertThat( + WebSocketOriginPolicy.browsersAndServices(Set.of("https://app.example.com")) + .safeFor(WebSocketAuthenticationProfile.SESSION_COOKIE)) + .isFalse(); + assertThat( + WebSocketOriginPolicy.browsersOnly(Set.of("https://app.example.com")) + .safeFor(WebSocketAuthenticationProfile.SESSION_COOKIE)) + .isTrue(); + } + + @Test + @DisplayName("origin matching is exact") + void originMatchingIsExact() { + WebSocketOriginPolicy policy = + WebSocketOriginPolicy.browsersOnly(Set.of("https://app.example.com")); + + assertThat(policy.permits("https://evil-app.example.com")).isFalse(); + assertThat(policy.permits("https://app.example.com.attacker.net")).isFalse(); + assertThat(policy.permits("http://app.example.com")).isFalse(); + assertThat(policy.permits("HTTPS://APP.EXAMPLE.COM")).isTrue(); + } + + @Test + @DisplayName("a wildcard origin is refused at construction") + void wildcardOriginIsRefused() { + assertThatThrownBy(() -> WebSocketOriginPolicy.browsersOnly(Set.of("https://*.example.com"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("matches nothing"); + } + + @Test + @DisplayName("an unsupported subprotocol is refused after the actor is known") + void unsupportedSubprotocolIsRefusedLast() { + HandshakeDecision decision = + pipeline( + WebSocketAuthenticationProfile.ONE_TIME_TICKET, + WebSocketOriginPolicy.browsersOnly(Set.of("https://app.example.com")), + 100) + .admit( + new HandshakeRequest( + PATH, + Optional.of("https://app.example.com"), + List.of(new WebSocketSubprotocolName("vendor.other.v1.json")), + Optional.of(mintTicket()), + Optional.empty(), + false, + "203.0.113.4"), + NOW); + + // 400, not 426: the client did upgrade; what it offered is not spoken here. + assertThat(decision.refusalStatus()).isEqualTo(400); + } + + @Test + @DisplayName("an admitted decision always has an actor") + void admittedDecisionAlwaysHasAnActor() { + assertThatThrownBy(() -> new HandshakeDecision(true, Optional.empty(), Optional.empty(), 0, "")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("nobody attached to it"); + } + + /** A store whose redeem is atomic and which counts how often it was reached. */ + private static final class RecordingTicketStore implements WebSocketTicketStore { + + private final Map held = new ConcurrentHashMap<>(); + private final AtomicInteger redemptions = new AtomicInteger(); + + @Override + public void issue(WebSocketConnectionTicket ticket) { + held.put(ticket.value(), ticket); + } + + @Override + public Optional redeem(String value, Instant now) { + redemptions.incrementAndGet(); + // remove(), not get()-then-remove(). Two concurrent handshakes must not both find it. + return Optional.ofNullable(held.remove(value)); + } + + @Override + public int purgeExpired(Instant now) { + int before = held.size(); + held.values().removeIf(ticket -> !ticket.validAt(now)); + return before - held.size(); + } + + private int redemptions() { + return redemptions.get(); + } + } +} diff --git a/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/idempotency/InMemoryCommittedResultLedger.java b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/idempotency/InMemoryCommittedResultLedger.java new file mode 100644 index 00000000..eaf4b5ab --- /dev/null +++ b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/idempotency/InMemoryCommittedResultLedger.java @@ -0,0 +1,121 @@ +package dev.caskeleton.adapter.inbound.websocket.idempotency; + +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +/** + * A ledger for tests, written to the same rules the real one must follow. + * + *

In memory, and therefore not a ledger anyone should deploy — the SPI's whole point is that a + * usable implementation writes in the application's own transaction, which this cannot. It exists + * to exercise the state machine, and it is deliberately no laxer than a correct implementation: the + * claim is atomic, an expired lease with no outcome reports UNKNOWN rather than NEW, and a + * committed entry is never overwritten. + */ +final class InMemoryCommittedResultLedger implements CommittedResultLedger { + + private record Entry( + WebSocketCommandOutcome outcome, Optional result, Instant leaseExpiresAt) {} + + private final Map entries = new ConcurrentHashMap<>(); + + @Override + public WebSocketCommandOutcome claim( + WebSocketCommandKey key, Instant now, Duration leaseDuration) { + Objects.requireNonNull(key, "key"); + Entry claim = + new Entry(WebSocketCommandOutcome.IN_FLIGHT, Optional.empty(), now.plus(leaseDuration)); + Entry existing = entries.putIfAbsent(key.storageKey(), claim); + if (existing == null) { + return WebSocketCommandOutcome.NEW; + } + return switch (existing.outcome()) { + case COMMITTED -> WebSocketCommandOutcome.COMMITTED; + // A failure is a completed attempt that changed nothing, so the next attempt may take it. + case FAILED -> + entries.replace(key.storageKey(), existing, claim) + ? WebSocketCommandOutcome.NEW + : WebSocketCommandOutcome.IN_FLIGHT; + case IN_FLIGHT -> + // A lapsed lease with no outcome is the crash window. Reporting NEW here would let a + // retry re-run work that may already be durable, which is precisely the bug UNKNOWN + // exists to keep visible. + now.isBefore(existing.leaseExpiresAt()) + ? WebSocketCommandOutcome.IN_FLIGHT + : WebSocketCommandOutcome.UNKNOWN; + case NEW, UNKNOWN -> WebSocketCommandOutcome.UNKNOWN; + }; + } + + @Override + public void recordCommitted(WebSocketCommandKey key, String encodedResult, Instant at) { + entries.put( + key.storageKey(), + new Entry(WebSocketCommandOutcome.COMMITTED, Optional.of(encodedResult), at)); + } + + @Override + public void recordFailed(WebSocketCommandKey key, Instant at) { + entries.computeIfPresent( + key.storageKey(), + (storageKey, existing) -> + existing.outcome() == WebSocketCommandOutcome.COMMITTED + // Never downgrade a committed entry. A late failure report for work that already + // committed would make the next attempt run it again. + ? existing + : new Entry(WebSocketCommandOutcome.FAILED, Optional.empty(), at)); + } + + @Override + public Optional committedResult(WebSocketCommandKey key, Instant now) { + Entry entry = entries.get(key.storageKey()); + return entry != null && entry.outcome() == WebSocketCommandOutcome.COMMITTED + ? entry.result() + : Optional.empty(); + } + + @Override + public WebSocketCommandOutcome outcome(WebSocketCommandKey key, Instant now) { + Entry entry = entries.get(key.storageKey()); + if (entry == null) { + return WebSocketCommandOutcome.NEW; + } + if (entry.outcome() == WebSocketCommandOutcome.IN_FLIGHT + && !now.isBefore(entry.leaseExpiresAt())) { + return WebSocketCommandOutcome.UNKNOWN; + } + return entry.outcome(); + } + + @Override + public List unresolved(Instant now) { + List stranded = new ArrayList<>(); + entries.forEach( + (storageKey, entry) -> { + if (entry.outcome() == WebSocketCommandOutcome.IN_FLIGHT + && !now.isBefore(entry.leaseExpiresAt())) { + stranded.add(parse(storageKey)); + } + }); + return List.copyOf(stranded); + } + + /** Simulates a crash: the claim stands and no outcome is ever recorded. */ + void abandonClaim(WebSocketCommandKey key) { + // Nothing to do beyond leaving the entry in place; that is exactly what a dead process leaves. + } + + private static WebSocketCommandKey parse(String storageKey) { + String[] parts = storageKey.split("\u001f", -1); + return new WebSocketCommandKey( + new dev.caskeleton.adapter.inbound.websocket.core.WebSocketEndpointName(parts[0]), + new dev.caskeleton.adapter.inbound.websocket.core.WebSocketActorReference(parts[1]), + new dev.caskeleton.adapter.inbound.websocket.protocol.WebSocketMessageId(parts[2])); + } +} diff --git a/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/idempotency/WebSocketCommandIdempotencyTest.java b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/idempotency/WebSocketCommandIdempotencyTest.java new file mode 100644 index 00000000..4d278a71 --- /dev/null +++ b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/idempotency/WebSocketCommandIdempotencyTest.java @@ -0,0 +1,172 @@ +package dev.caskeleton.adapter.inbound.websocket.idempotency; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketActorReference; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketEndpointName; +import dev.caskeleton.adapter.inbound.websocket.protocol.WebSocketMessageId; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Command idempotency across a reconnect, which is the case a WebSocket has and HTTP does not. + * + *

A client that loses its connection reconnects and replays whatever it never saw an answer for. + * That happens on a new connection with a new id, so anything scoped to the connection is inert + * exactly when it is needed — and the replay lands as concurrent duplicates, because reconnect + * storms deliver them all at once. + */ +class WebSocketCommandIdempotencyTest { + + private static final Instant NOW = Instant.parse("2026-08-25T10:00:00Z"); + private static final Duration LEASE = Duration.ofSeconds(30); + private static final byte[] SALT = + "a-deployment-salt-of-adequate-length".getBytes(StandardCharsets.UTF_8); + + private final InMemoryCommittedResultLedger ledger = new InMemoryCommittedResultLedger(); + + private static WebSocketCommandKey key(String messageId) { + return new WebSocketCommandKey( + new WebSocketEndpointName("live-updates"), + WebSocketActorReference.of("alice", "acme", SALT), + new WebSocketMessageId(messageId)); + } + + @Test + @DisplayName("a first attempt wins the claim") + void firstAttemptWins() { + assertThat(ledger.claim(key("m-1"), NOW, LEASE)).isEqualTo(WebSocketCommandOutcome.NEW); + } + + @Test + @DisplayName("the key survives a reconnect") + void keySurvivesAReconnect() { + // Scoped to the actor and the client's own message id, never to the connection. A key that + // included the connection would treat every replay as a new command. + assertThat(key("m-1")).isEqualTo(key("m-1")); + assertThat(key("m-1").storageKey()).doesNotContain("c-"); + } + + @Test + @DisplayName("a concurrent duplicate is told the command is in flight") + void concurrentDuplicateIsInFlight() { + ledger.claim(key("m-1"), NOW, LEASE); + + assertThat(ledger.claim(key("m-1"), NOW.plusSeconds(1), LEASE)) + .isEqualTo(WebSocketCommandOutcome.IN_FLIGHT); + } + + @Test + @DisplayName("a replay after commit gets the stored result, not a second execution") + void replayAfterCommitReturnsTheStoredResult() { + ledger.claim(key("m-1"), NOW, LEASE); + ledger.recordCommitted(key("m-1"), "{\"orderId\":\"o-1\"}", NOW.plusSeconds(1)); + + assertThat(ledger.claim(key("m-1"), NOW.plusSeconds(2), LEASE)) + .isEqualTo(WebSocketCommandOutcome.COMMITTED); + assertThat(ledger.committedResult(key("m-1"), NOW.plusSeconds(2))) + .contains("{\"orderId\":\"o-1\"}"); + } + + @Test + @DisplayName("a failed attempt may be retried") + void failedAttemptMayBeRetried() { + ledger.claim(key("m-1"), NOW, LEASE); + ledger.recordFailed(key("m-1"), NOW.plusSeconds(1)); + + assertThat(ledger.claim(key("m-1"), NOW.plusSeconds(2), LEASE)) + .isEqualTo(WebSocketCommandOutcome.NEW); + } + + @Test + @DisplayName("a crash between commit and ledger write reports UNKNOWN, never NEW") + void crashWindowReportsUnknown() { + // The state that must not be guessed. Reporting NEW would let the retry re-run work that may + // already be durable; reporting COMMITTED would invent a result. + ledger.claim(key("m-1"), NOW, LEASE); + ledger.abandonClaim(key("m-1")); + + assertThat(ledger.claim(key("m-1"), NOW.plus(LEASE).plusSeconds(1), LEASE)) + .isEqualTo(WebSocketCommandOutcome.UNKNOWN); + assertThat(ledger.unresolved(NOW.plus(LEASE).plusSeconds(1))).containsExactly(key("m-1")); + } + + @Test + @DisplayName("a late failure report never downgrades a committed entry") + void lateFailureDoesNotDowngradeACommit() { + // Otherwise the next attempt runs work that already committed. + ledger.claim(key("m-1"), NOW, LEASE); + ledger.recordCommitted(key("m-1"), "{}", NOW.plusSeconds(1)); + ledger.recordFailed(key("m-1"), NOW.plusSeconds(2)); + + assertThat(ledger.outcome(key("m-1"), NOW.plusSeconds(3))) + .isEqualTo(WebSocketCommandOutcome.COMMITTED); + } + + @Test + @DisplayName("two actors' commands never collide") + void actorsAreIsolated() { + WebSocketCommandKey mallory = + new WebSocketCommandKey( + new WebSocketEndpointName("live-updates"), + WebSocketActorReference.of("mallory", "acme", SALT), + new WebSocketMessageId("m-1")); + + ledger.claim(key("m-1"), NOW, LEASE); + + assertThat(ledger.claim(mallory, NOW, LEASE)).isEqualTo(WebSocketCommandOutcome.NEW); + } + + @Test + @DisplayName("the storage key cannot be forged by a component's own content") + void storageKeyCannotBeForged() { + // Joined on a unit separator, which no component's grammar admits. A printable separator would + // let one component's value forge a boundary and make two different commands share a key. + assertThat(key("m-1").storageKey()).contains("\u001f"); + assertThat(key("m-1").storageKey()).isNotEqualTo(key("m-2").storageKey()); + } + + @Test + @DisplayName("a committed verdict must carry the result to replay") + void committedVerdictNeedsAResult() { + // Otherwise the client is told it succeeded with nothing to show for it. + assertThatThrownBy( + () -> + new CommandReconciliation.Verdict( + CommandReconciliation.State.COMMITTED, java.util.Optional.empty())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("nothing to show for it"); + } + + @Test + @DisplayName("an indeterminate verdict is a real answer, not a fallback to one of the others") + void indeterminateIsItsOwnAnswer() { + // An operator can act on a command that says it does not know. Nobody can act on one that + // quietly picked. + CommandReconciliation.Verdict verdict = CommandReconciliation.Verdict.indeterminate(); + + assertThat(verdict.state()).isEqualTo(CommandReconciliation.State.INDETERMINATE); + assertThat(verdict.encodedResult()).isEmpty(); + } + + @Test + @DisplayName("reconciliation resolves what the ledger cannot") + void reconciliationResolvesTheCrashWindow() { + ledger.claim(key("m-1"), NOW, LEASE); + ledger.abandonClaim(key("m-1")); + Instant later = NOW.plus(LEASE).plusSeconds(1); + assertThat(ledger.outcome(key("m-1"), later)).isEqualTo(WebSocketCommandOutcome.UNKNOWN); + + // Only the business data can settle it, so the platform asks rather than guesses. + CommandReconciliation reconciliation = + (commandKey, now) -> CommandReconciliation.Verdict.committed("{\"orderId\":\"o-1\"}"); + CommandReconciliation.Verdict verdict = reconciliation.reconcile(key("m-1"), later); + + assertThat(verdict.state()).isEqualTo(CommandReconciliation.State.COMMITTED); + assertThat(verdict.encodedResult()).contains("{\"orderId\":\"o-1\"}"); + } +} diff --git a/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/inbound/FragmentAssemblerTest.java b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/inbound/FragmentAssemblerTest.java new file mode 100644 index 00000000..ac7e0b80 --- /dev/null +++ b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/inbound/FragmentAssemblerTest.java @@ -0,0 +1,122 @@ +package dev.caskeleton.adapter.inbound.websocket.inbound; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.inbound.websocket.budget.WebSocketConnectionBudget; +import java.time.Duration; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Reassembly, which is the inbound attack surface a WebSocket has and HTTP does not. + * + *

A peer may send a first frame and then continuation frames indefinitely. A naive assembler + * grows a buffer for as long as they keep coming: no frame is oversized, nothing throws, and the + * memory is charged to a connection that looks healthy. + */ +class FragmentAssemblerTest { + + private static final WebSocketConnectionBudget BUDGET = + new WebSocketConnectionBudget( + 64, 256, 8, 100, 1024, Duration.ofHours(4), Duration.ofSeconds(90)); + + private final FragmentAssembler assembler = new FragmentAssembler(BUDGET); + + @Test + @DisplayName("an unfragmented message passes through without buffering") + void unfragmentedMessagePassesThrough() { + assertThat(assembler.accept("hello", true)).contains("hello"); + assertThat(assembler.bufferedLength()).isZero(); + } + + @Test + @DisplayName("fragments are joined in order") + void fragmentsAreJoined() { + assertThat(assembler.accept("he", false)).isEmpty(); + assertThat(assembler.accept("ll", false)).isEmpty(); + assertThat(assembler.accept("o", true)).contains("hello"); + } + + @Test + @DisplayName("an oversized frame is refused") + void oversizedFrameIsRefused() { + assertThatThrownBy(() -> assembler.accept("x".repeat(65), true)) + .isInstanceOf(FragmentAssemblyException.class) + .hasMessageContaining("exceeds 64 bytes"); + } + + @Test + @DisplayName("many ordinary frames adding up are refused") + void accumulatedSizeIsRefused() { + // No individual frame is oversized. This is the shape a frame bound alone does not catch. + assertThatThrownBy( + () -> { + for (int index = 0; index < 8; index++) { + assembler.accept("x".repeat(60), false); + } + }) + .isInstanceOf(FragmentAssemblyException.class) + .hasMessageContaining("reassembled message exceeds"); + } + + @Test + @DisplayName("an endless stream of tiny fragments is refused") + void endlessTinyFragmentsAreRefused() { + // Well inside both size bounds and costing a copy each. This is the bound that is usually + // missing. + assertThatThrownBy( + () -> { + for (int index = 0; index < 20; index++) { + assembler.accept("x", false); + } + }) + .isInstanceOf(FragmentAssemblyException.class) + .hasMessageContaining("costing a copy each"); + } + + @Test + @DisplayName("a refused sequence leaves nothing buffered") + void refusalResetsTheBuffer() { + // Otherwise a peer that repeatedly fails assembly still occupies memory between attempts. + try { + for (int index = 0; index < 20; index++) { + assembler.accept("x", false); + } + } catch (FragmentAssemblyException expected) { + // the bound fired, which is the point + } + + assertThat(assembler.assembling()).isFalse(); + assertThat(assembler.bufferedLength()).isZero(); + } + + @Test + @DisplayName("a reset releases the grown buffer, not just its contents") + void resetReleasesCapacity() { + // StringBuilder keeps its capacity. A connection that once received a large message would hold + // that array for the rest of its life — hours, for a connection that never sends again. + assembler.accept("x".repeat(60), false); + assembler.accept("x".repeat(60), true); + + assembler.reset(); + + assertThat(assembler.bufferedLength()).isZero(); + assertThat(assembler.assembling()).isFalse(); + } + + @Test + @DisplayName("the assembler is per connection") + void assemblerIsPerConnection() { + // A shared one interleaves two peers' fragments into one message, which is both a corruption + // and a disclosure. + FragmentAssembler alice = new FragmentAssembler(BUDGET); + FragmentAssembler bob = new FragmentAssembler(BUDGET); + + alice.accept("alice", false); + bob.accept("bob", false); + + assertThat(alice.accept("-done", true)).contains("alice-done"); + assertThat(bob.accept("-done", true)).contains("bob-done"); + } +} diff --git a/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/lifecycle/ConnectionLifecycleTest.java b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/lifecycle/ConnectionLifecycleTest.java new file mode 100644 index 00000000..01b95ffe --- /dev/null +++ b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/lifecycle/ConnectionLifecycleTest.java @@ -0,0 +1,168 @@ +package dev.caskeleton.adapter.inbound.websocket.lifecycle; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketActorReference; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketConnectionContext; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketConnectionId; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketConnectionState; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketCredentialExpiry; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketEndpointName; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketNodeId; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketSessionId; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketSubprotocolName; +import dev.caskeleton.adapter.inbound.websocket.error.WebSocketCloseCode; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Liveness and shutdown, the two places a long-lived connection costs more than a request. + * + *

TCP does not report a departed peer. A closed laptop lid, a phone switching to cellular, a NAT + * forgetting its mapping — none produce a FIN, and the connection keeps its buffers, its registry + * entry and its subscriptions until something checks. + */ +class ConnectionLifecycleTest { + + private static final Instant NOW = Instant.parse("2026-08-25T10:00:00Z"); + private static final byte[] SALT = + "a-deployment-salt-of-adequate-length".getBytes(StandardCharsets.UTF_8); + + @Test + @DisplayName("the standard heartbeat tolerates a dropped ping") + void standardHeartbeatToleratesADroppedPing() { + // A timeout below two intervals closes a healthy connection on one dropped ping, and the + // reconnect storm makes the congestion that dropped it worse. + HeartbeatPolicy policy = HeartbeatPolicy.standard(); + + assertThat(policy.toleratedMissedPings()).isGreaterThanOrEqualTo(2); + } + + @Test + @DisplayName("a timeout under two ping intervals is refused") + void tooTightTimeoutIsRefused() { + assertThatThrownBy(() -> new HeartbeatPolicy(Duration.ofSeconds(15), Duration.ofSeconds(20))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("one dropped ping"); + } + + @Test + @DisplayName("a ping slower than common intermediaries is refused") + void tooSlowPingIsRefused() { + // Intermediaries commonly drop an idle connection at 30-60s and say nothing. A slower ping + // lets the liveness check be the thing that never notices. + assertThatThrownBy(() -> new HeartbeatPolicy(Duration.ofSeconds(60), Duration.ofSeconds(180))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("before the server checks"); + } + + @Test + @DisplayName("a ping falls due and silence eventually expires") + void pingAndIdleFire() { + HeartbeatPolicy policy = HeartbeatPolicy.standard(); + + assertThat(policy.pingDue(NOW, NOW.plusSeconds(5))).isFalse(); + assertThat(policy.pingDue(NOW, NOW.plusSeconds(15))).isTrue(); + assertThat(policy.idleExpired(NOW, NOW.plusSeconds(30))).isFalse(); + assertThat(policy.idleExpired(NOW, NOW.plusSeconds(45))).isTrue(); + } + + @Test + @DisplayName("a shutdown closes with going-away, not normal") + void shutdownUsesGoingAway() { + // A client told "going away" reconnects. One told "normal" may reasonably conclude the session + // is over and never come back. + assertThat(CloseOrchestration.codeFor(CloseOrchestration.CloseReason.NODE_SHUTTING_DOWN)) + .isEqualTo(WebSocketCloseCode.GOING_AWAY); + assertThat(CloseOrchestration.codeFor(CloseOrchestration.CloseReason.CLIENT_REQUESTED)) + .isEqualTo(WebSocketCloseCode.NORMAL); + } + + @Test + @DisplayName("closing stops inbound before anything else") + void closingStopsInboundFirst() { + WebSocketConnectionContext open = context(WebSocketConnectionState.OPEN); + + assertThat( + CloseOrchestration.nextStep(open, 0, Optional.empty(), false, NOW, NOW.plusSeconds(10))) + .isEqualTo(CloseOrchestration.Step.STOP_ACCEPTING_INBOUND); + } + + @Test + @DisplayName("a draining connection finishes what is queued before the close frame") + void drainingFlushesFirst() { + WebSocketConnectionContext draining = context(WebSocketConnectionState.DRAINING); + + assertThat( + CloseOrchestration.nextStep( + draining, 3, Optional.empty(), false, NOW, NOW.plusSeconds(10))) + .isEqualTo(CloseOrchestration.Step.FLUSH_PENDING); + } + + @Test + @DisplayName("the drain is bounded, so a stopped reader cannot hold the deploy") + void drainIsBounded() { + WebSocketConnectionContext draining = context(WebSocketConnectionState.DRAINING); + + assertThat( + CloseOrchestration.nextStep( + draining, 3, Optional.empty(), false, NOW.plusSeconds(11), NOW.plusSeconds(10))) + .isEqualTo(CloseOrchestration.Step.SEND_CLOSE_FRAME); + } + + @Test + @DisplayName("the close handshake is awaited briefly and then abandoned") + void closeHandshakeIsBounded() { + // Skipping straight to teardown gives the client a 1006 — "closed abnormally, no reason" — + // which is indistinguishable from a network failure and triggers its most aggressive reconnect + // path during a fleet restart. Waiting for ever is how a shutdown hangs. + WebSocketConnectionContext draining = context(WebSocketConnectionState.DRAINING); + Instant sentAt = NOW; + + assertThat( + CloseOrchestration.nextStep( + draining, 0, Optional.of(sentAt), false, NOW.plusSeconds(1), NOW.plusSeconds(10))) + .isEqualTo(CloseOrchestration.Step.AWAIT_PEER_CLOSE); + assertThat( + CloseOrchestration.nextStep( + draining, 0, Optional.of(sentAt), false, NOW.plusSeconds(30), NOW.plusSeconds(10))) + .isEqualTo(CloseOrchestration.Step.TEAR_DOWN); + } + + @Test + @DisplayName("the peer answering ends the handshake immediately") + void peerCloseEndsTheHandshake() { + WebSocketConnectionContext draining = context(WebSocketConnectionState.DRAINING); + + assertThat( + CloseOrchestration.nextStep( + draining, 0, Optional.of(NOW), true, NOW.plusSeconds(1), NOW.plusSeconds(10))) + .isEqualTo(CloseOrchestration.Step.TEAR_DOWN); + } + + @Test + @DisplayName("every close reason maps to a code") + void everyReasonHasACode() { + for (CloseOrchestration.CloseReason reason : CloseOrchestration.CloseReason.values()) { + assertThat(CloseOrchestration.codeFor(reason)).isNotNull(); + } + } + + private static WebSocketConnectionContext context(WebSocketConnectionState state) { + return new WebSocketConnectionContext( + new WebSocketConnectionId("c-01H8XQ2N4K"), + new WebSocketSessionId("s-01H8XQ2N4K"), + new WebSocketNodeId("edge-1"), + new WebSocketEndpointName("live-updates"), + Optional.of(WebSocketSubprotocolName.stable()), + WebSocketActorReference.of("alice", "acme", SALT), + state, + WebSocketCredentialExpiry.never(), + NOW); + } +} diff --git a/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/moduleboundary/WebSocketBuildModel.java b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/moduleboundary/WebSocketBuildModel.java new file mode 100644 index 00000000..006bfb27 --- /dev/null +++ b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/moduleboundary/WebSocketBuildModel.java @@ -0,0 +1,263 @@ +package dev.caskeleton.adapter.inbound.websocket.moduleboundary; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.TreeMap; +import java.util.TreeSet; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +/** + * Reads a web platform source tree and reports where it disagrees with the declared module map. + * + *

This deliberately works on source text rather than on compiled classes. An import that a + * boundary forbids has usually been erased by the compiler — a constant is inlined, a type is only + * named in a signature that is not retained — so a bytecode scan would report a boundary as clean + * while the source a reviewer reads still crosses it. + * + *

It lives in the test source set on purpose. Scanning a checkout is build-time work: a running + * application has no source tree to scan, and a runtime scan would fail exactly in the packaged + * environments where nothing is wrong. + * + *

Every entry point refuses to report success on an empty scan. A boundary rule that passes + * because it found no files is the failure mode this whole model exists to prevent. + */ +public final class WebSocketBuildModel { + + /** + * Import prefixes that make a module framework bound. + * + *

The design's constraint names Servlet, Spring MVC, Spring WebFlux and Reactor. The set here + * is wider on purpose: {@code jakarta} covers servlet and validation, {@code com.fasterxml} + * covers Jackson, {@code io.micrometer} covers observation. Each binds a module to a runtime just + * as firmly as Spring does, and a "core" module that can only be exercised with Jackson on the + * classpath is not the portable decision the split exists to protect. + */ + public static final Pattern FRAMEWORK_IMPORT = + Pattern.compile( + // `tools.jackson` is Jackson 3 and `com.fasterxml` is Jackson 2. Both are listed because + // this repository runs on Spring 7, whose message converters take Jackson 3 — so a CORE + // module could have imported a mapper without this detector noticing, which is a hole in + // exactly the check that is supposed to have none. + "^(org\\.springframework|jakarta|reactor|io\\.micrometer|com\\.fasterxml" + + "|tools\\.jackson|org\\.slf4j|io\\.swagger)\\."); + + private static final Pattern IMPORT_STATEMENT = + Pattern.compile("^\\s*import\\s+(?:static\\s+)?([\\w.]+)\\s*;", Pattern.MULTILINE); + + private static final Pattern PACKAGE_STATEMENT = + Pattern.compile("^\\s*package\\s+([\\w.]+)\\s*;", Pattern.MULTILINE); + + private WebSocketBuildModel() {} + + /** Scans every source root the platform's module map governs. */ + public static WebSocketSourceGraph scanPlatformSources() { + return scan(platformSourceRoots()); + } + + /** + * Scans a Java source root — the directory that directly contains the {@code dev} package folder. + * + * @throws IllegalStateException when the root holds no platform source at all + */ + public static WebSocketSourceGraph scan(Path sourceRoot) { + return scan(List.of(sourceRoot)); + } + + /** + * Scans several Java source roots as one platform. + * + * @throws IllegalStateException when the roots hold no platform source at all + */ + public static WebSocketSourceGraph scan(List sourceRoots) { + Map> edges = new TreeMap<>(); + Map> frameworkImports = new TreeMap<>(); + Set packages = new TreeSet<>(); + int fileCount = 0; + + List files = new ArrayList<>(); + for (Path sourceRoot : sourceRoots) { + files.addAll(javaFilesUnder(sourceRoot)); + } + for (Path file : files) { + String source = read(file); + String packageName = declaredPackage(source).orElse(null); + if (packageName == null || !WebSocketModuleBoundary.insidePlatform(packageName)) { + continue; + } + fileCount++; + packages.add(packageName); + String moduleId = WebSocketModuleBoundary.moduleIdForPackage(packageName).orElse(null); + if (moduleId == null) { + // An unregistered package still has to appear in `packages` so the rule can name it, but it + // owns no module identity and therefore contributes no edges. + continue; + } + edges.computeIfAbsent(moduleId, key -> new TreeSet<>()); + frameworkImports.computeIfAbsent(moduleId, key -> new TreeSet<>()); + + Matcher matcher = IMPORT_STATEMENT.matcher(source); + while (matcher.find()) { + String imported = matcher.group(1); + if (WebSocketModuleBoundary.insidePlatform(imported)) { + importedModule(imported) + .filter(target -> !target.equals(moduleId)) + .ifPresent(target -> edges.get(moduleId).add(target)); + } else if (FRAMEWORK_IMPORT.matcher(imported).find()) { + frameworkImports.get(moduleId).add(imported); + } + } + } + + if (fileCount == 0) { + throw new IllegalStateException( + "no web platform source was found under " + + sourceRoots.stream().map(root -> root.toAbsolutePath().toString()).toList() + + "; a boundary rule must never pass by scanning nothing"); + } + return new WebSocketSourceGraph(edges, frameworkImports, packages, fileCount); + } + + /** Packages that hold source but were never given a module identity. */ + public static List undeclaredPackages(WebSocketSourceGraph graph) { + return graph.packages().stream() + .filter(packageName -> WebSocketModuleBoundary.moduleIdForPackage(packageName).isEmpty()) + .sorted() + .toList(); + } + + /** Declared modules whose package holds no source, so the declaration describes nothing. */ + public static List declaredButAbsentModules(WebSocketSourceGraph graph) { + List absent = new ArrayList<>(); + WebSocketModuleBoundary.packagesById() + .forEach( + (moduleId, packageName) -> { + boolean present = + graph.packages().stream() + .anyMatch( + scanned -> + scanned.equals(packageName) || scanned.startsWith(packageName + ".")); + if (!present) { + absent.add(moduleId + " (" + packageName + ")"); + } + }); + absent.sort(String::compareTo); + return List.copyOf(absent); + } + + /** Imports that cross a module boundary the declaration does not allow. */ + public static List undeclaredEdges(WebSocketSourceGraph graph) { + List violations = new ArrayList<>(); + graph + .moduleEdges() + .forEach( + (from, targets) -> + targets.stream() + .filter(to -> !WebSocketModuleBoundary.edgeAllowed(from, to)) + .forEach(to -> violations.add(from + " -> " + to))); + violations.sort(String::compareTo); + return List.copyOf(violations); + } + + /** Framework imports found in modules declared {@link WebSocketModulePurity#CORE}. */ + public static List frameworkImportsInCoreModules(WebSocketSourceGraph graph) { + Set core = WebSocketModuleBoundary.coreModuleIds(); + List violations = new ArrayList<>(); + graph + .frameworkImports() + .forEach( + (moduleId, imports) -> { + if (!core.contains(moduleId)) { + return; + } + imports.forEach(imported -> violations.add(moduleId + " imports " + imported)); + }); + violations.sort(String::compareTo); + return List.copyOf(violations); + } + + /** Every source root the module map governs. */ + public static List platformSourceRoots() { + return List.of(mainSourceRoot()); + } + + /** + * Locates this leaf's production source root. + * + * @throws IllegalStateException when it cannot be found, rather than returning a path that would + * scan to zero files + */ + public static Path mainSourceRoot() { + String packagePath = WebSocketModuleBoundary.PACKAGE_ROOT.replace('.', '/'); + for (Path directory = Path.of("").toAbsolutePath(); + directory != null; + directory = directory.getParent()) { + Path candidate = directory.resolve("src").resolve("main").resolve("java"); + if (Files.isDirectory(candidate.resolve(packagePath))) { + return candidate; + } + } + throw new IllegalStateException( + "cannot locate src/main/java/" + + packagePath + + " from " + + Path.of("").toAbsolutePath() + + "; the module boundary rules have nothing to check"); + } + + private static Optional importedModule(String importedType) { + int lastDot = importedType.lastIndexOf('.'); + if (lastDot < 0) { + return Optional.empty(); + } + // A static import names a member, so peel qualifiers until one resolves to a declared module. + for (String candidate = importedType.substring(0, lastDot); + candidate.length() >= WebSocketModuleBoundary.PACKAGE_ROOT.length(); + candidate = candidate.substring(0, Math.max(candidate.lastIndexOf('.'), 0))) { + Optional moduleId = WebSocketModuleBoundary.moduleIdForPackage(candidate); + if (moduleId.isPresent()) { + return moduleId; + } + if (candidate.lastIndexOf('.') < 0) { + break; + } + } + return Optional.empty(); + } + + private static Optional declaredPackage(String source) { + Matcher matcher = PACKAGE_STATEMENT.matcher(source); + return matcher.find() ? Optional.of(matcher.group(1)) : Optional.empty(); + } + + private static List javaFilesUnder(Path root) { + if (!Files.isDirectory(root)) { + throw new IllegalStateException("source root does not exist: " + root.toAbsolutePath()); + } + try (Stream files = Files.walk(root)) { + return files + .filter(Files::isRegularFile) + .filter(path -> path.getFileName().toString().endsWith(".java")) + .sorted() + .toList(); + } catch (IOException ex) { + throw new UncheckedIOException(ex); + } + } + + private static String read(Path file) { + try { + return Files.readString(file); + } catch (IOException ex) { + throw new UncheckedIOException(ex); + } + } +} diff --git a/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/moduleboundary/WebSocketModuleBoundaryTest.java b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/moduleboundary/WebSocketModuleBoundaryTest.java new file mode 100644 index 00000000..fb9be7a0 --- /dev/null +++ b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/moduleboundary/WebSocketModuleBoundaryTest.java @@ -0,0 +1,147 @@ +package dev.caskeleton.adapter.inbound.websocket.moduleboundary; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * The declared module map has to match the source tree, in both directions. + * + *

This is what makes the eighteen-modules-as-packages adaptation honest rather than a claim. A + * design module boundary that only exists in a document is a boundary the first refactor walks + * through; scanned and enforced, promoting a package to its own Gradle leaf later is a registry + * edit rather than an archaeology exercise. + * + *

The negative cases run against a temporary tree. Asserting only that the real tree is clean + * would leave the detector itself untested, and a detector that finds nothing looks identical to a + * codebase with nothing to find. + */ +class WebSocketModuleBoundaryTest { + + private static final String ROOT = WebSocketModuleBoundary.PACKAGE_ROOT; + + private static WebSocketSourceGraph production; + + @BeforeAll + static void scanProductionTree() { + production = WebSocketBuildModel.scanPlatformSources(); + } + + @Test + @DisplayName("the scan actually reads the production tree") + void theScanActuallyReadsTheProductionTree() { + // A scan pointed at the wrong directory finds nothing and reports every boundary as clean. + assertThat(production.fileCount()).isGreaterThan(5); + assertThat(production.packages()).contains(ROOT + ".moduleboundary"); + } + + @Test + @DisplayName("every production package has a declared module identity") + void everyProductionPackageHasADeclaredModuleIdentity() { + assertThat(WebSocketBuildModel.undeclaredPackages(production)) + .as("add the package to WebSocketStableModule before shipping it") + .isEmpty(); + } + + @Test + @DisplayName("every cross-module import is a declared edge") + void everyCrossModuleImportIsADeclaredEdge() { + assertThat(WebSocketBuildModel.undeclaredEdges(production)).isEmpty(); + } + + @Test + @DisplayName("core modules stay framework free") + void coreModulesStayFrameworkFree() { + // The strictest rule in this platform. A CORE module that could name a WebSocketSession would + // make the same decision untestable without a running container and unportable between the + // two runtimes. + assertThat(WebSocketBuildModel.frameworkImportsInCoreModules(production)).isEmpty(); + } + + @Test + @DisplayName("the servlet and reactive runtimes declare no edge to each other") + void theTwoRuntimesAreMutuallyExclusive() { + // The absent edge is the load-bearing one. With it, the reactive runtime compiles against a + // servlet session type, and a deployment ends up shipping both stacks and starting neither. + assertThat(WebSocketStableModule.SERVLET.allowedEdges()).doesNotContain("webflux"); + assertThat(WebSocketStableModule.WEBFLUX.allowedEdges()).doesNotContain("servlet"); + } + + @Test + @DisplayName("an undeclared cross-module import is rejected") + void anUndeclaredCrossModuleImportIsRejected(@TempDir Path tree) { + writeType(tree, ROOT + ".ordering", "LeakySequence", ROOT + ".security.ConnectionTicket"); + + assertThat(WebSocketBuildModel.undeclaredEdges(WebSocketBuildModel.scan(tree))) + .containsExactly("ordering -> security"); + } + + @Test + @DisplayName("a framework import in a core module is rejected") + void aFrameworkImportInACoreModuleIsRejected(@TempDir Path tree) { + writeType( + tree, ROOT + ".protocol", "LeakyEnvelope", "org.springframework.stereotype.Component"); + + assertThat(WebSocketBuildModel.frameworkImportsInCoreModules(WebSocketBuildModel.scan(tree))) + .containsExactly("protocol imports org.springframework.stereotype.Component"); + } + + @Test + @DisplayName("a transport session type in a core module is rejected") + void aTransportSessionInACoreModuleIsRejected(@TempDir Path tree) { + // The specific leak this platform is most exposed to: a long-lived connection makes reaching + // for the container's own session type tempting from everywhere. + writeType( + tree, + ROOT + ".session", + "LeakyRegistry", + "org.springframework.web.socket.WebSocketSession"); + + assertThat(WebSocketBuildModel.frameworkImportsInCoreModules(WebSocketBuildModel.scan(tree))) + .containsExactly("session imports org.springframework.web.socket.WebSocketSession"); + } + + @Test + @DisplayName("no two modules claim the same package") + void packagesAreClaimedOnce() { + // Two ids on one package makes ownership depend on iteration order, so an edge check silently + // consults whichever declaration won — and the other module's rules apply to nothing. It + // happened once while adding a module and nothing else noticed. + assertThat(WebSocketStableModule.packagesById().values()).doesNotHaveDuplicates(); + } + + @Test + @DisplayName("a package with no declared identity is rejected") + void aPackageWithNoDeclaredIdentityIsRejected(@TempDir Path tree) { + writeType(tree, ROOT + ".undeclared", "Stowaway", null); + + assertThat(WebSocketBuildModel.undeclaredPackages(WebSocketBuildModel.scan(tree))) + .containsExactly(ROOT + ".undeclared"); + } + + private static void writeType( + Path sourceRoot, String packageName, String typeName, String importedType) { + Path directory = sourceRoot.resolve(packageName.replace('.', '/')); + try { + Files.createDirectories(directory); + Files.writeString( + directory.resolve(typeName + ".java"), + "package " + + packageName + + ";\n\n" + + (importedType == null ? "" : "import " + importedType + ";\n\n") + + "final class " + + typeName + + " {}\n"); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } +} diff --git a/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/moduleboundary/WebSocketSourceGraph.java b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/moduleboundary/WebSocketSourceGraph.java new file mode 100644 index 00000000..7ddc9088 --- /dev/null +++ b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/moduleboundary/WebSocketSourceGraph.java @@ -0,0 +1,30 @@ +package dev.caskeleton.adapter.inbound.websocket.moduleboundary; + +import java.util.Map; +import java.util.Set; + +/** + * What a scan of a web platform source tree actually found. + * + *

Everything here is observed, never declared: {@link WebSocketStableModule} says what the + * module map is supposed to be, and this record says what the checkout is. The boundary rules are + * the comparison between the two. + * + * @param moduleEdges module identifier to the module identifiers it imports, self-edges excluded + * @param frameworkImports module identifier to the framework imports it uses, empty when pure + * @param packages every package that contained at least one Java file + * @param fileCount how many Java files were read, so a rule can refuse to pass on an empty scan + */ +public record WebSocketSourceGraph( + Map> moduleEdges, + Map> frameworkImports, + Set packages, + int fileCount) { + + /** Canonicalises the collections so callers cannot mutate a scan result. */ + public WebSocketSourceGraph { + moduleEdges = Map.copyOf(moduleEdges); + frameworkImports = Map.copyOf(frameworkImports); + packages = Set.copyOf(packages); + } +} diff --git a/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/observability/WebSocketObservabilityTest.java b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/observability/WebSocketObservabilityTest.java new file mode 100644 index 00000000..7f6040c2 --- /dev/null +++ b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/observability/WebSocketObservabilityTest.java @@ -0,0 +1,97 @@ +package dev.caskeleton.adapter.inbound.websocket.observability; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketActorReference; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketConnectionContext; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketConnectionId; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketConnectionState; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketCredentialExpiry; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketEndpointName; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketNodeId; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketSessionId; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketSubprotocolName; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Telemetry for something that lives for hours rather than milliseconds. + * + *

Both failure modes are worse here than for a request. A high-cardinality tag creates series + * that outlive the connections and never stop accumulating; a sensitive log field is written on + * every message for hours rather than once. + */ +class WebSocketObservabilityTest { + + private static final byte[] SALT = + "a-deployment-salt-of-adequate-length".getBytes(StandardCharsets.UTF_8); + + @Test + @DisplayName("the connection identifier is not a metric tag") + void connectionIdIsNotATag() { + // One series per connection that ever existed, and the series outlive the connections. + assertThat(WebSocketMetricTags.allowed("connectionId")).isFalse(); + assertThat(WebSocketMetricTags.allowed("sessionId")).isFalse(); + assertThat(WebSocketMetricTags.allowed("actor")).isFalse(); + } + + @Test + @DisplayName("the node identifier is a tag, because a fleet has a knowable number of nodes") + void nodeIdIsATag() { + assertThat(WebSocketMetricTags.allowed("nodeId")).isTrue(); + assertThat(WebSocketMetricTags.allowed("endpoint")).isTrue(); + assertThat(WebSocketMetricTags.allowed("closeCode")).isTrue(); + } + + @Test + @DisplayName("a forbidden tag is refused where it is recorded") + void forbiddenTagIsRefused() { + assertThatThrownBy(() -> WebSocketMetricTags.require("correlationId")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("never stop accumulating"); + } + + @Test + @DisplayName("connection log fields carry no payload and no raw identity") + void logFieldsCarryNoPayloadOrIdentity() { + Map fields = SafeWebSocketLogFields.of(context()); + + assertThat(fields).containsKeys("connectionId", "endpoint", "nodeId", "state", "actor"); + assertThat(fields).doesNotContainKey("payload"); + // The actor is the short fingerprint: enough to correlate while reading, not enough to name. + assertThat(fields.get("actor")).hasSize(12); + assertThat(fields.values()).noneMatch(value -> value.contains("alice")); + } + + @Test + @DisplayName("a payload field is never safe to log, at any level") + void payloadIsNeverSafeToLog() { + // A payload log behind a debug flag is a payload log the first time somebody debugs a + // production incident — exactly when the data is most sensitive and the flag most likely to + // be left on. + assertThat(SafeWebSocketLogFields.safeFieldName("payload")).isFalse(); + assertThat(SafeWebSocketLogFields.safeFieldName("messageBody")).isFalse(); + assertThat(SafeWebSocketLogFields.safeFieldName("ticket")).isFalse(); + assertThat(SafeWebSocketLogFields.safeFieldName("authorization")).isFalse(); + assertThat(SafeWebSocketLogFields.safeFieldName("endpoint")).isTrue(); + } + + private static WebSocketConnectionContext context() { + Instant now = Instant.parse("2026-08-25T10:00:00Z"); + return new WebSocketConnectionContext( + new WebSocketConnectionId("c-01H8XQ2N4K"), + new WebSocketSessionId("s-01H8XQ2N4K"), + new WebSocketNodeId("edge-1"), + new WebSocketEndpointName("live-updates"), + Optional.of(WebSocketSubprotocolName.stable()), + WebSocketActorReference.of("alice", "acme", SALT), + WebSocketConnectionState.OPEN, + WebSocketCredentialExpiry.never(), + now); + } +} diff --git a/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/ordering/StreamOrderingTest.java b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/ordering/StreamOrderingTest.java new file mode 100644 index 00000000..f0b0705a --- /dev/null +++ b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/ordering/StreamOrderingTest.java @@ -0,0 +1,108 @@ +package dev.caskeleton.adapter.inbound.websocket.ordering; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Ordering, which a WebSocket makes look free and is not. + * + *

Frames on one connection arrive in order, so total order looks like a property of the + * transport. It is not: a reconnect starts a new connection, and anything produced concurrently was + * never ordered. A client that assumed otherwise is wrong exactly when it reconnects. + */ +class StreamOrderingTest { + + private final StreamSequencer sequencer = new StreamSequencer(); + private final GapDetector detector = new GapDetector(); + + @Test + @DisplayName("sequences start at 1, not 0") + void sequencesStartAtOne() { + // Zero is what an uninitialised field holds, so starting there makes "never sent anything" and + // "sent the first message" the same observation on the client. + assertThat(sequencer.current("orders")).isZero(); + assertThat(sequencer.next("orders")).isOne(); + } + + @Test + @DisplayName("each stream has its own counter") + void countersArePerStream() { + // Per stream because the number is what a client resumes from. A connection-scoped counter + // restarts at zero on the new connection, and "resume from 4210" then means nothing. + sequencer.next("orders"); + sequencer.next("orders"); + + assertThat(sequencer.next("prices")).isOne(); + assertThat(sequencer.next("orders")).isEqualTo(3); + } + + @Test + @DisplayName("a forgotten stream does not keep its counter") + void forgettingReleasesTheCounter() { + // Otherwise the map grows with the number of streams that ever existed rather than with the + // number in use. + sequencer.next("orders"); + sequencer.forget("orders"); + + assertThat(sequencer.trackedStreams()).isZero(); + } + + @Test + @DisplayName("consecutive positions are in order") + void consecutivePositionsAreInOrder() { + assertThat(detector.observe("orders", 1)).isEqualTo(GapDetector.Observation.IN_ORDER); + assertThat(detector.observe("orders", 2)).isEqualTo(GapDetector.Observation.IN_ORDER); + } + + @Test + @DisplayName("a skipped position is a gap, with a size") + void skippedPositionIsAGap() { + detector.observe("orders", 1); + + assertThat(detector.gapSize("orders", 5)).isEqualTo(3); + assertThat(detector.observe("orders", 5)).isEqualTo(GapDetector.Observation.GAP); + } + + @Test + @DisplayName("a repeat and a reorder are distinguished") + void repeatAndReorderAreDistinct() { + // They mean different things: a duplicate is a retry that was not deduplicated, a reorder is + // something writing out of order, which no profile permits. + detector.observe("orders", 5); + + assertThat(detector.observe("orders", 5)).isEqualTo(GapDetector.Observation.DUPLICATE); + assertThat(detector.observe("orders", 3)).isEqualTo(GapDetector.Observation.REORDERED); + } + + @Test + @DisplayName("a reorder does not lower the high-water mark") + void reorderKeepsTheHighWaterMark() { + // Lowering it would report every subsequent in-order message as a duplicate. + detector.observe("orders", 5); + detector.observe("orders", 3); + + assertThat(detector.lastSeen("orders")).isEqualTo(5); + assertThat(detector.observe("orders", 6)).isEqualTo(GapDetector.Observation.IN_ORDER); + } + + @Test + @DisplayName("gaps are errors only where total order was promised") + void gapsMeanDifferentThingsPerProfile() { + // The reason the profile is declared: under a lossy profile a gap is expected, and a client + // that resynchronised on every one would never stop. + assertThat(OrderingProfile.PER_STREAM_TOTAL.gapsAreErrors()).isTrue(); + assertThat(OrderingProfile.MONOTONIC_LOSSY.gapsAreErrors()).isFalse(); + assertThat(OrderingProfile.BEST_EFFORT.gapsAreErrors()).isFalse(); + } + + @Test + @DisplayName("a conflated stream is monotonic without being complete") + void conflatedStreamIsMonotonicLossy() { + // Without this profile a conflated stream must claim total order — which conflation breaks — + // or best effort, which understates what it gives. + assertThat(OrderingProfile.MONOTONIC_LOSSY.monotonic()).isTrue(); + assertThat(OrderingProfile.BEST_EFFORT.monotonic()).isFalse(); + } +} diff --git a/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/outbound/OutboundQueueSnapshotTest.java b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/outbound/OutboundQueueSnapshotTest.java new file mode 100644 index 00000000..d5e466ba --- /dev/null +++ b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/outbound/OutboundQueueSnapshotTest.java @@ -0,0 +1,113 @@ +package dev.caskeleton.adapter.inbound.websocket.outbound; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.inbound.websocket.budget.WebSocketConnectionBudget; +import dev.caskeleton.adapter.inbound.websocket.protocol.WebSocketMessageId; +import java.time.Duration; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * What an operator can see about one connection's queue, and what they must not. + * + *

The observation exists because backpressure is invisible otherwise: a connection whose queue + * is filling looks identical to a healthy one until it is closed. The distinction that carries the + * weight is between a drop and an overflow — the first is the DROP policy doing its job, the second + * is a lossless stream that has lost something and a client that has to reconcile. + */ +class OutboundQueueSnapshotTest { + + private static final WebSocketConnectionBudget BUDGET = + new WebSocketConnectionBudget( + 64 * 1024, 512 * 1024, 16, 100, 1024, Duration.ofHours(4), Duration.ofSeconds(90)); + + private final OutboundQueue queue = + new OutboundQueue(BUDGET, 2, new GlobalBufferBudget(1_000_000)); + + private static OutboundMessage message(String id, OutboundDelivery delivery) { + return new OutboundMessage( + new WebSocketMessageId(id), "payload", delivery, OutboundPriority.EVENT, Optional.empty()); + } + + @Test + @DisplayName("an idle queue reports nothing to reconcile") + void idleQueueIsQuiet() { + OutboundQueueSnapshot snapshot = queue.snapshot(); + + assertThat(snapshot.messageCount()).isZero(); + assertThat(snapshot.byteCount()).isZero(); + assertThat(snapshot.droppedCount()).isZero(); + assertThat(snapshot.requiresReconciliation()).isFalse(); + } + + @Test + @DisplayName("the snapshot follows what the queue actually holds") + void snapshotTracksTheQueue() { + queue.offer(message("m-1", OutboundDelivery.DROPPABLE)); + + assertThat(queue.snapshot().messageCount()).isEqualTo(1); + assertThat(queue.snapshot().byteCount()).isEqualTo(queue.bufferedBytes()); + + queue.poll(); + + assertThat(queue.snapshot().messageCount()).isZero(); + } + + @Test + @DisplayName("a drop under the DROP policy is counted and is not an overflow") + void dropIsCountedButIsNotOverflow() { + // Working as configured. If this raised the reconciliation flag, every deployment that chose + // best-effort delivery would page somebody for behaving as it asked to. + for (int index = 0; index < 3; index++) { + queue.offer(message("m-" + index, OutboundDelivery.DROPPABLE)); + } + + OutboundQueueSnapshot snapshot = queue.snapshot(); + + assertThat(snapshot.droppedCount()).isEqualTo(1); + assertThat(snapshot.requiresReconciliation()).isFalse(); + } + + @Test + @DisplayName("a refused guaranteed message marks the connection for reconciliation") + void losslessOverflowRequiresReconciliation() { + // The case the flag exists for: the stream promised delivery and could not keep it, so the + // connection is closed and the client resumes rather than continuing on a silent gap. + queue.offer(message("m-1", OutboundDelivery.GUARANTEED)); + queue.offer(message("m-2", OutboundDelivery.GUARANTEED)); + + assertThat(queue.offer(message("m-3", OutboundDelivery.GUARANTEED))) + .isEqualTo(OutboundEnqueueResult.CONNECTION_MUST_CLOSE); + assertThat(queue.snapshot().requiresReconciliation()).isTrue(); + } + + @Test + @DisplayName("the overflow mark survives the queue draining") + void overflowMarkSurvivesDraining() { + // Draining is what happens next, and it empties the counts. If the mark went with them the + // close decision would depend on when the snapshot was taken. + queue.offer(message("m-1", OutboundDelivery.GUARANTEED)); + queue.offer(message("m-2", OutboundDelivery.GUARANTEED)); + queue.offer(message("m-3", OutboundDelivery.GUARANTEED)); + while (queue.poll().isPresent()) { + // drain + } + + assertThat(queue.snapshot().messageCount()).isZero(); + assertThat(queue.snapshot().requiresReconciliation()).isTrue(); + } + + @Test + @DisplayName("a negative observation is refused rather than reported") + void negativeObservationIsRefused() { + assertThatThrownBy(() -> new OutboundQueueSnapshot(-1, 0, 0, false)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new OutboundQueueSnapshot(0, -1, 0, false)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new OutboundQueueSnapshot(0, 0, -1, false)) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/outbound/OutboundQueueTest.java b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/outbound/OutboundQueueTest.java new file mode 100644 index 00000000..4f8bd0db --- /dev/null +++ b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/outbound/OutboundQueueTest.java @@ -0,0 +1,225 @@ +package dev.caskeleton.adapter.inbound.websocket.outbound; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.inbound.websocket.budget.WebSocketConnectionBudget; +import dev.caskeleton.adapter.inbound.websocket.protocol.WebSocketMessageId; +import java.time.Duration; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Backpressure, which is where a WebSocket differs from a request most expensively. + * + *

A slow HTTP client stalls its own response and nothing else. A slow WebSocket consumer does + * not fail at all — it reads more slowly than the server writes, and the difference accumulates in + * the server's heap with no error anywhere. Every case here is about bounding that. + */ +class OutboundQueueTest { + + private static final WebSocketConnectionBudget BUDGET = + new WebSocketConnectionBudget( + 64 * 1024, 512 * 1024, 16, 100, 1024, Duration.ofHours(4), Duration.ofSeconds(90)); + + private final GlobalBufferBudget global = new GlobalBufferBudget(4096); + private final OutboundQueue queue = new OutboundQueue(BUDGET, 4, global); + + private static OutboundMessage message( + String id, String payload, OutboundDelivery delivery, OutboundPriority priority) { + return new OutboundMessage( + new WebSocketMessageId(id), payload, delivery, priority, Optional.empty()); + } + + private static OutboundMessage conflatable(String id, String payload, String key) { + return new OutboundMessage( + new WebSocketMessageId(id), + payload, + OutboundDelivery.CONFLATABLE, + OutboundPriority.EVENT, + Optional.of(key)); + } + + @Test + @DisplayName("control messages leave before application data") + void controlOutranksApplicationData() { + // Under backpressure the queue is full of application data, and a close notice queued behind + // it arrives after the close. + queue.offer(message("m-1", "event", OutboundDelivery.DROPPABLE, OutboundPriority.EVENT)); + queue.offer(message("m-2", "reply", OutboundDelivery.GUARANTEED, OutboundPriority.RESPONSE)); + queue.offer(message("m-3", "close", OutboundDelivery.GUARANTEED, OutboundPriority.CONTROL)); + + assertThat(queue.poll().orElseThrow().messageId().value()).isEqualTo("m-3"); + assertThat(queue.poll().orElseThrow().messageId().value()).isEqualTo("m-2"); + assertThat(queue.poll().orElseThrow().messageId().value()).isEqualTo("m-1"); + } + + @Test + @DisplayName("messages of equal priority keep their order") + void equalPriorityKeepsArrivalOrder() { + // Reordering two events on one stream is a correctness bug for anything the client applies in + // sequence, and a priority queue with no tiebreak reorders equal elements freely. + for (int index = 0; index < 4; index++) { + queue.offer( + message("m-" + index, "e" + index, OutboundDelivery.DROPPABLE, OutboundPriority.EVENT)); + } + + for (int index = 0; index < 4; index++) { + assertThat(queue.poll().orElseThrow().messageId().value()).isEqualTo("m-" + index); + } + } + + @Test + @DisplayName("a full queue drops what may be dropped") + void fullQueueDropsDroppable() { + for (int index = 0; index < 4; index++) { + assertThat( + queue.offer( + message("m-" + index, "e", OutboundDelivery.DROPPABLE, OutboundPriority.EVENT))) + .isEqualTo(OutboundEnqueueResult.ACCEPTED); + } + + assertThat(queue.offer(message("m-9", "e", OutboundDelivery.DROPPABLE, OutboundPriority.EVENT))) + .isEqualTo(OutboundEnqueueResult.DROPPED); + assertThat(queue.droppedMessages()).isOne(); + } + + @Test + @DisplayName("a full queue closes the connection rather than losing a guaranteed message") + void fullQueueClosesForGuaranteed() { + // A client that reconnects and resynchronises has lost nothing; one that silently missed a + // guaranteed message is wrong and does not know it. + for (int index = 0; index < 4; index++) { + queue.offer(message("m-" + index, "e", OutboundDelivery.DROPPABLE, OutboundPriority.EVENT)); + } + + assertThat( + queue.offer( + message("m-9", "receipt", OutboundDelivery.GUARANTEED, OutboundPriority.RESPONSE))) + .isEqualTo(OutboundEnqueueResult.CONNECTION_MUST_CLOSE); + } + + @Test + @DisplayName("a conflatable message replaces its predecessor rather than queueing behind it") + void conflationReplacesInPlace() { + // The queue holds one entry per key instead of a history the peer will never catch up with. + assertThat(queue.offer(conflatable("m-1", "price=1", "AAPL"))) + .isEqualTo(OutboundEnqueueResult.ACCEPTED); + assertThat(queue.offer(conflatable("m-2", "price=2", "AAPL"))) + .isEqualTo(OutboundEnqueueResult.CONFLATED); + + assertThat(queue.size()).isOne(); + assertThat(queue.poll().orElseThrow().payload()).isEqualTo("price=2"); + } + + @Test + @DisplayName("conflation accounts for the replacement's own size") + void conflationReAccountsBytes() { + // Assuming the sizes match would let a growing conflated value drift past both bounds one byte + // at a time. + queue.offer(conflatable("m-1", "x", "AAPL")); + long afterFirst = queue.bufferedBytes(); + + queue.offer(conflatable("m-2", "x".repeat(50), "AAPL")); + + assertThat(queue.bufferedBytes()).isGreaterThan(afterFirst); + assertThat(queue.bufferedBytes()).isEqualTo(50); + } + + @Test + @DisplayName("a byte bound refuses what a count bound admits") + void byteBoundIsSeparateFromCountBound() { + // Four messages is inside the count bound; four large ones are not inside the byte bound. + OutboundQueue byteBounded = new OutboundQueue(BUDGET, 100, new GlobalBufferBudget(1024 * 1024)); + + for (int index = 0; index < 4; index++) { + byteBounded.offer( + message( + "m-" + index, "x".repeat(300), OutboundDelivery.DROPPABLE, OutboundPriority.EVENT)); + } + + assertThat(byteBounded.size()).isLessThan(5); + assertThat(byteBounded.bufferedBytes()).isLessThanOrEqualTo(1024); + } + + @Test + @DisplayName("the node ceiling refuses a connection that is individually within its bound") + void nodeCeilingBindsAcrossConnections() { + // The bound a per-connection limit cannot provide: a megabyte each is fine at a hundred + // connections and is the whole heap at fifty thousand. + GlobalBufferBudget tiny = new GlobalBufferBudget(100); + OutboundQueue first = new OutboundQueue(BUDGET, 100, tiny); + OutboundQueue second = new OutboundQueue(BUDGET, 100, tiny); + + assertThat( + first.offer( + message("m-1", "x".repeat(80), OutboundDelivery.DROPPABLE, OutboundPriority.EVENT))) + .isEqualTo(OutboundEnqueueResult.ACCEPTED); + // Well inside its own connection's bound, and the node has no room. + assertThat( + second.offer( + message("m-2", "x".repeat(80), OutboundDelivery.DROPPABLE, OutboundPriority.EVENT))) + .isEqualTo(OutboundEnqueueResult.DROPPED); + } + + @Test + @DisplayName("writing a message returns its space to the node") + void pollingReleasesNodeCapacity() { + GlobalBufferBudget shared = new GlobalBufferBudget(100); + OutboundQueue connection = new OutboundQueue(BUDGET, 100, shared); + connection.offer( + message("m-1", "x".repeat(80), OutboundDelivery.DROPPABLE, OutboundPriority.EVENT)); + + connection.poll(); + + assertThat(shared.buffered()).isZero(); + assertThat(shared.peak()).isEqualTo(80); + } + + @Test + @DisplayName("closing a connection with a backlog returns its space") + void discardingReleasesNodeCapacity() { + // Otherwise the node leaks capacity for every connection that ever closed while behind, which + // is every connection that was ever slow. + GlobalBufferBudget shared = new GlobalBufferBudget(1000); + OutboundQueue connection = new OutboundQueue(BUDGET, 100, shared); + for (int index = 0; index < 5; index++) { + connection.offer( + message( + "m-" + index, "x".repeat(100), OutboundDelivery.DROPPABLE, OutboundPriority.EVENT)); + } + + connection.discardAll(); + + assertThat(shared.buffered()).isZero(); + assertThat(connection.size()).isZero(); + } + + @Test + @DisplayName("the node budget cannot be driven negative by a double release") + void doubleReleaseCannotCreateCapacity() { + GlobalBufferBudget budget = new GlobalBufferBudget(100); + budget.reserve(50); + + budget.release(50); + budget.release(50); + + assertThat(budget.buffered()).isZero(); + } + + @Test + @DisplayName("a conflatable message without a key is refused") + void conflatableNeedsAKey() { + assertThatThrownBy( + () -> + new OutboundMessage( + new WebSocketMessageId("m-1"), + "x", + OutboundDelivery.CONFLATABLE, + OutboundPriority.EVENT, + Optional.empty())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("degrades to dropping"); + } +} diff --git a/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/outbound/SerializedOutboundWriterTest.java b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/outbound/SerializedOutboundWriterTest.java new file mode 100644 index 00000000..42d5bb45 --- /dev/null +++ b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/outbound/SerializedOutboundWriterTest.java @@ -0,0 +1,174 @@ +package dev.caskeleton.adapter.inbound.websocket.outbound; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.inbound.websocket.budget.WebSocketConnectionBudget; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketConnectionId; +import dev.caskeleton.adapter.inbound.websocket.protocol.WebSocketMessageId; +import java.time.Duration; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Serialised writing, which both WebSocket APIs require and neither enforces. + * + *

Concurrent sends on one session are undefined in the servlet and reactive APIs alike. What + * "undefined" means in practice is frame interleaving: one message's continuation frames land + * inside another's, the peer's parser rejects the stream, and the failure looks like corruption + * from a network that a packet capture shows nothing wrong with. + */ +class SerializedOutboundWriterTest { + + private static final WebSocketConnectionBudget BUDGET = + new WebSocketConnectionBudget( + 64 * 1024, 512 * 1024, 16, 100, 64 * 1024, Duration.ofHours(4), Duration.ofSeconds(90)); + + private final GlobalBufferBudget global = new GlobalBufferBudget(1024 * 1024); + + private static OutboundMessage message(String id, String payload) { + return new OutboundMessage( + new WebSocketMessageId(id), + payload, + OutboundDelivery.GUARANTEED, + OutboundPriority.RESPONSE, + Optional.empty()); + } + + @Test + @DisplayName("only one thread is ever inside the sink") + void writesAreSerialised() throws Exception { + // The property the whole design rests on. A sink that observes two concurrent callers is a + // session receiving interleaved frames. + AtomicInteger concurrent = new AtomicInteger(); + AtomicInteger maxConcurrent = new AtomicInteger(); + OutboundQueue queue = new OutboundQueue(BUDGET, 1000, global); + SerializedOutboundWriter writer = + new SerializedOutboundWriter( + new WebSocketConnectionId("c-01H8XQ2N4K"), + queue, + payload -> { + int inside = concurrent.incrementAndGet(); + maxConcurrent.accumulateAndGet(inside, Math::max); + Thread.sleep(1); + concurrent.decrementAndGet(); + }); + + CountDownLatch start = new CountDownLatch(1); + try (ExecutorService pool = Executors.newFixedThreadPool(8)) { + Future[] futures = new Future[64]; + for (int index = 0; index < futures.length; index++) { + int id = index; + futures[index] = + pool.submit( + () -> { + start.await(); + writer.offer(message("m-" + id, "payload-" + id)); + return null; + }); + } + start.countDown(); + for (Future future : futures) { + future.get(60, TimeUnit.SECONDS); + } + } + writer.drain(); + + assertThat(maxConcurrent).hasValue(1); + assertThat(writer.written()).isEqualTo(64); + } + + @Test + @DisplayName("nothing is stranded when a caller finds the writer busy") + void nothingIsStrandedWhenBusy() throws Exception { + // tryLock returns immediately rather than blocking, so the queued message has to be picked up + // by whoever is already writing. If it were not, a message would sit in the queue for ever. + List written = new CopyOnWriteArrayList<>(); + OutboundQueue queue = new OutboundQueue(BUDGET, 1000, global); + SerializedOutboundWriter writer = + new SerializedOutboundWriter( + new WebSocketConnectionId("c-01H8XQ2N4K"), queue, written::add); + + try (ExecutorService pool = Executors.newFixedThreadPool(4)) { + Future[] futures = new Future[40]; + for (int index = 0; index < futures.length; index++) { + int id = index; + futures[index] = pool.submit(() -> writer.offer(message("m-" + id, "p" + id))); + } + for (Future future : futures) { + future.get(30, TimeUnit.SECONDS); + } + } + writer.drain(); + + assertThat(written).hasSize(40); + assertThat(queue.size()).isZero(); + } + + @Test + @DisplayName("a refused write stops the writer instead of continuing down the queue") + void refusedWriteStopsTheWriter() { + // On a half-closed connection the remaining writes succeed silently into nothing, so + // continuing would report everything as written while the peer received one message. + AtomicInteger attempts = new AtomicInteger(); + OutboundQueue queue = new OutboundQueue(BUDGET, 1000, global); + SerializedOutboundWriter writer = + new SerializedOutboundWriter( + new WebSocketConnectionId("c-01H8XQ2N4K"), + queue, + payload -> { + attempts.incrementAndGet(); + throw new java.io.IOException("the session is closed"); + }); + + for (int index = 0; index < 5; index++) { + writer.offer(message("m-" + index, "p")); + } + + assertThat(attempts).hasValue(1); + assertThat(writer.closed()).isTrue(); + assertThat(writer.failed()).isOne(); + } + + @Test + @DisplayName("a closed writer refuses further messages") + void closedWriterRefusesFurtherMessages() { + OutboundQueue queue = new OutboundQueue(BUDGET, 1000, global); + SerializedOutboundWriter writer = + new SerializedOutboundWriter( + new WebSocketConnectionId("c-01H8XQ2N4K"), queue, payload -> {}); + + writer.close(); + + assertThat(writer.offer(message("m-1", "p"))) + .isEqualTo(OutboundEnqueueResult.CONNECTION_MUST_CLOSE); + } + + @Test + @DisplayName("closing returns the connection's buffered bytes to the node") + void closingReleasesNodeCapacity() { + // Otherwise the node-wide budget keeps counting bytes for a connection that no longer exists. + GlobalBufferBudget shared = new GlobalBufferBudget(10_000); + OutboundQueue queue = new OutboundQueue(BUDGET, 1000, shared); + SerializedOutboundWriter writer = + new SerializedOutboundWriter( + new WebSocketConnectionId("c-01H8XQ2N4K"), + queue, + payload -> { + throw new java.io.IOException("stalled"); + }); + + writer.offer(message("m-1", "x".repeat(500))); + writer.close(); + + assertThat(shared.buffered()).isZero(); + } +} diff --git a/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/performance/SlowConsumerGateTest.java b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/performance/SlowConsumerGateTest.java new file mode 100644 index 00000000..0cffb181 --- /dev/null +++ b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/performance/SlowConsumerGateTest.java @@ -0,0 +1,12 @@ +package dev.caskeleton.adapter.inbound.websocket.performance; + +import dev.caskeleton.adapter.inbound.websocket.testkit.runtime.SlowConsumerContract; + +/** + * The slow-consumer gate. + * + *

No container: the property is about the platform's own accounting, and a real socket would add + * the container's send buffer as a second, unmeasured place for bytes to sit — which would make the + * assertion about the container rather than about the bound. + */ +class SlowConsumerGateTest extends SlowConsumerContract {} diff --git a/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/protocol/WebSocketEnvelopeTest.java b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/protocol/WebSocketEnvelopeTest.java new file mode 100644 index 00000000..6beca1ae --- /dev/null +++ b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/protocol/WebSocketEnvelopeTest.java @@ -0,0 +1,186 @@ +package dev.caskeleton.adapter.inbound.websocket.protocol; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Instant; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * The envelope's rules, each of which prevents one specific wrong behaviour downstream. + * + *

A connection multiplexes many in-flight messages of different kinds. Without per-family field + * rules, every optional field is optional on every message and the receiver has to guess which ones + * mean anything — which is how a response gets routed to the wrong waiter and a gap alarm fires for + * a stream nobody ordered. + */ +class WebSocketEnvelopeTest { + + private static final Instant NOW = Instant.parse("2026-08-25T10:00:00Z"); + private static final WebSocketMessageId ID = new WebSocketMessageId("m-1"); + private static final WebSocketMessageType TYPE = new WebSocketMessageType("order.place.v1"); + + @Test + @DisplayName("a message type is a published name, never a Java class name") + void messageTypeIsNotAClassName() { + // A class name on the wire publishes the package layout, breaks every client on a rename, and + // makes the receiver's type resolution an attack surface. + assertThatThrownBy( + () -> new WebSocketMessageType("dev.caskeleton.domain.order.PlaceOrderCommand")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("attack surface"); + assertThatThrownBy(() -> new WebSocketMessageType("java.util.HashMap")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("a message type carries its version") + void messageTypeIsVersioned() { + assertThat(TYPE.version()).isOne(); + assertThat(TYPE.unversionedName()).isEqualTo("order.place"); + assertThatThrownBy(() -> new WebSocketMessageType("order.place")) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("a response must name what it answers") + void responseRequiresCorrelation() { + // Without it the waiter never resolves, and on a multiplexed connection there is no other way + // to tell whose answer this is. + assertThatThrownBy( + () -> + new WebSocketEnvelope( + ID, + WebSocketMessageFamily.RESPONSE, + TYPE, + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.of("{}"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("never resolves"); + } + + @Test + @DisplayName("an unordered family may not carry a sequence") + void unorderedFamilyRejectsSequence() { + // A sequence on a command invites gap detection on something the sender never ordered, and + // every resulting missing-message alarm is about nothing. + assertThatThrownBy( + () -> + new WebSocketEnvelope( + ID, + WebSocketMessageFamily.COMMAND, + TYPE, + Optional.empty(), + Optional.of("stream-1"), + Optional.of(1L), + Optional.empty(), + Optional.of("{}"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("about nothing"); + } + + @Test + @DisplayName("a stream id and a sequence are meaningless apart") + void orderingFieldsComeTogether() { + // A stream without positions cannot be gap-checked; a position without a stream cannot be + // compared to anything. + assertThatThrownBy(() -> WebSocketEnvelope.event(ID, TYPE, "stream-1", null, "{}")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("meaningless apart"); + assertThatThrownBy(() -> WebSocketEnvelope.event(ID, TYPE, null, 1L, "{}")) + .isInstanceOf(IllegalArgumentException.class); + assertThatCode(() -> WebSocketEnvelope.event(ID, TYPE, "stream-1", 1L, "{}")) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("an unordered event is allowed to carry neither") + void eventMayBeUnordered() { + assertThatCode(() -> WebSocketEnvelope.event(ID, TYPE, null, null, "{}")) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("only a command may declare an expiry") + void onlyCommandsExpire() { + // An event that arrives late is still news; a response's deadline is one the sender cannot act + // on. + assertThatCode(() -> WebSocketEnvelope.command(ID, TYPE, "{}", NOW.plusSeconds(5))) + .doesNotThrowAnyException(); + assertThatThrownBy( + () -> + new WebSocketEnvelope( + ID, + WebSocketMessageFamily.EVENT, + TYPE, + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.of(NOW), + Optional.of("{}"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("only a command expires"); + } + + @Test + @DisplayName("an expired command is detectable before the handler runs") + void expiredCommandIsDetectable() { + // The client that gave up waiting has usually retried. Running both is the duplicate the + // deadline exists to prevent. + WebSocketEnvelope command = WebSocketEnvelope.command(ID, TYPE, "{}", NOW.plusSeconds(5)); + + assertThat(command.expiredAt(NOW)).isFalse(); + assertThat(command.expiredAt(NOW.plusSeconds(5))).isTrue(); + assertThat(WebSocketEnvelope.command(ID, TYPE, "{}", null).expiredAt(NOW)).isFalse(); + } + + @Test + @DisplayName("a heartbeat carries no payload") + void heartbeatCarriesNothing() { + assertThatThrownBy( + () -> + new WebSocketEnvelope( + ID, + WebSocketMessageFamily.HEARTBEAT, + TYPE, + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.of("{}"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("carries no payload"); + } + + @Test + @DisplayName("the payload stays an encoded document, not a map") + void payloadIsOpaque() { + // The record component's type is the assertion. A Map here would accept any shape, defer every + // validation to whichever handler reaches for a missing key, make an entity trivially + // serializable onto the wire, and bring unbounded nesting with it. + java.lang.reflect.RecordComponent payload = + java.util.Arrays.stream(WebSocketEnvelope.class.getRecordComponents()) + .filter(component -> component.getName().equals("payload")) + .findFirst() + .orElseThrow(); + + assertThat(payload.getGenericType().getTypeName()) + .isEqualTo("java.util.Optional"); + } + + @Test + @DisplayName("a correlation id is a different type from a message id") + void correlationIsADistinctType() { + // Mixing "my identity" with "what I am answering" routes a response to the wrong waiter, and + // one caller receiving another's data is a disclosure rather than a bug. + assertThat((Object) new WebSocketMessageId("m-1")) + .isNotEqualTo(new WebSocketCorrelationId("m-1")); + assertThat(WebSocketCorrelationId.answering(ID).value()).isEqualTo("m-1"); + } +} diff --git a/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/protocol/WebSocketMessageDescriptorTest.java b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/protocol/WebSocketMessageDescriptorTest.java new file mode 100644 index 00000000..d43d9217 --- /dev/null +++ b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/protocol/WebSocketMessageDescriptorTest.java @@ -0,0 +1,165 @@ +package dev.caskeleton.adapter.inbound.websocket.protocol; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * The catalog, and the asymmetry a WebSocket does not provide on its own. + * + *

At the transport layer either end may send any frame at any time. That symmetry is not + * something an application wants, and nothing enforces the difference unless the catalog does. + */ +class WebSocketMessageDescriptorTest { + + private static final WebSocketMessageType PLACE = new WebSocketMessageType("order.place.v1"); + private static final WebSocketMessageType PLACED = new WebSocketMessageType("order.placed.v1"); + + private static final WebSocketMessageCatalog CATALOG = + WebSocketMessageCatalog.of( + List.of( + new WebSocketMessageDescriptor( + PLACE, + WebSocketMessageFamily.COMMAND, + WebSocketMessageDirection.CLIENT_TO_SERVER, + WebSocketSchemaVersion.v(1)), + new WebSocketMessageDescriptor( + PLACED, + WebSocketMessageFamily.EVENT, + WebSocketMessageDirection.SERVER_TO_CLIENT, + WebSocketSchemaVersion.v(1)))); + + @Test + @DisplayName("an unknown type is refused before any handler") + void unknownTypeIsRefused() { + // The security property of a closed catalog: a decoder that resolves an arbitrary name from + // the wire can be asked to resolve one nobody intended. + assertThat(CATALOG.find(new WebSocketMessageType("nope.v1"))).isEmpty(); + assertThat(CATALOG.acceptsFromClient(new WebSocketMessageType("nope.v1"))).isFalse(); + } + + @Test + @DisplayName("a client cannot send a server-only type") + void clientCannotSendAServerOnlyType() { + // Otherwise a client injects an event the server relays to other subscribers as though the + // server had produced it. + assertThat(CATALOG.acceptsFromClient(PLACED)).isFalse(); + assertThat(CATALOG.acceptsFromClient(PLACE)).isTrue(); + } + + @Test + @DisplayName("an unpublished type and a forbidden one are answered identically") + void unpublishedAndForbiddenAreIndistinguishable() { + // A client learns nothing about what exists that it may not use. + assertThat(CATALOG.admitFromClient(PLACED, WebSocketMessageFamily.EVENT)).isEmpty(); + assertThat( + CATALOG.admitFromClient( + new WebSocketMessageType("nope.v1"), WebSocketMessageFamily.EVENT)) + .isEmpty(); + } + + @Test + @DisplayName("a claimed family that disagrees with the catalog is refused") + void mislabelledFamilyIsRefused() { + // A client labelling a command as an event routes it past everything the platform does per + // family — the expiry check and the correlation requirement both key on it. + assertThat(CATALOG.admitFromClient(PLACE, WebSocketMessageFamily.EVENT)).isEmpty(); + assertThat(CATALOG.admitFromClient(PLACE, WebSocketMessageFamily.COMMAND)).isPresent(); + } + + @Test + @DisplayName("a duplicate type fails at startup") + void duplicateTypeFailsAtStartup() { + assertThatThrownBy( + () -> + WebSocketMessageCatalog.of( + List.of( + new WebSocketMessageDescriptor( + PLACE, + WebSocketMessageFamily.COMMAND, + WebSocketMessageDirection.CLIENT_TO_SERVER, + WebSocketSchemaVersion.v(1)), + new WebSocketMessageDescriptor( + PLACE, + WebSocketMessageFamily.EVENT, + WebSocketMessageDirection.SERVER_TO_CLIENT, + WebSocketSchemaVersion.v(1))))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("silently unreachable"); + } + + @Test + @DisplayName("the type name's version and the schema's major must agree") + void typeNameAndSchemaVersionMustAgree() { + assertThatThrownBy( + () -> + new WebSocketMessageDescriptor( + PLACE, + WebSocketMessageFamily.COMMAND, + WebSocketMessageDirection.CLIENT_TO_SERVER, + WebSocketSchemaVersion.v(2))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("different shape"); + } + + @Test + @DisplayName("a minor bump stays readable, a major bump does not") + void majorBreaksAndMinorDoesNot() { + // Conflating the two would make every additive change a breaking one. + assertThat(new WebSocketSchemaVersion(1, 3).readableBy(new WebSocketSchemaVersion(1, 0))) + .isTrue(); + assertThat(new WebSocketSchemaVersion(2, 0).readableBy(new WebSocketSchemaVersion(1, 9))) + .isFalse(); + } + + @Test + @DisplayName("the catalog fingerprint ignores registration order") + void fingerprintIgnoresRegistrationOrder() { + // Otherwise it reports drift caused by bean ordering, and everyone learns to ignore it. + List forward = CATALOG.all(); + List reversed = new java.util.ArrayList<>(forward); + java.util.Collections.reverse(reversed); + + assertThat(WebSocketMessageCatalog.of(reversed).fingerprint()).isEqualTo(CATALOG.fingerprint()); + } + + @Test + @DisplayName("the fingerprint changes when the published surface changes") + void fingerprintTracksThePublishedSurface() { + List extra = new java.util.ArrayList<>(CATALOG.all()); + extra.add( + new WebSocketMessageDescriptor( + new WebSocketMessageType("order.cancel.v1"), + WebSocketMessageFamily.COMMAND, + WebSocketMessageDirection.CLIENT_TO_SERVER, + WebSocketSchemaVersion.v(1))); + WebSocketMessageCatalog extended = WebSocketMessageCatalog.of(extra); + + assertThat(extended.fingerprint()).isNotEqualTo(CATALOG.fingerprint()); + } + + @Test + @DisplayName("an additive minor release does not report drift") + void additiveReleaseDoesNotReportDrift() { + // Both nodes can serve a minor bump, so reporting drift on every additive release would train + // people to ignore the signal that matters. + WebSocketMessageCatalog bumped = + WebSocketMessageCatalog.of( + List.of( + new WebSocketMessageDescriptor( + PLACE, + WebSocketMessageFamily.COMMAND, + WebSocketMessageDirection.CLIENT_TO_SERVER, + new WebSocketSchemaVersion(1, 4)), + new WebSocketMessageDescriptor( + PLACED, + WebSocketMessageFamily.EVENT, + WebSocketMessageDirection.SERVER_TO_CLIENT, + new WebSocketSchemaVersion(1, 2)))); + + assertThat(bumped.fingerprint()).isEqualTo(CATALOG.fingerprint()); + } +} diff --git a/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/protocol/WebSocketProtocolProfileTest.java b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/protocol/WebSocketProtocolProfileTest.java new file mode 100644 index 00000000..797ba860 --- /dev/null +++ b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/protocol/WebSocketProtocolProfileTest.java @@ -0,0 +1,92 @@ +package dev.caskeleton.adapter.inbound.websocket.protocol; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketSubprotocolName; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Subprotocol negotiation, which happens once and then cannot be revisited. + * + *

An HTTP request negotiates per call; a WebSocket negotiates at handshake and lives for hours + * on whatever was agreed. Getting it wrong is not a failed request — it is a connection that works + * until the message format changes, then fails in a way nobody associates with a deploy. + */ +class WebSocketProtocolProfileTest { + + private static final WebSocketSubprotocolName STABLE = WebSocketSubprotocolName.stable(); + private static final WebSocketSubprotocolName OTHER = + new WebSocketSubprotocolName("vendor.other.v1.json"); + + @Test + @DisplayName("the Stable subprotocol carries its version in the token") + void stableSubprotocolIsVersioned() { + // The only place a WebSocket can put a version. Without it both ends assume their own current + // format and find out later. + assertThat(STABLE.value()).isEqualTo("hyeonworks.realtime.v1.json"); + assertThat(STABLE.versioned()).isTrue(); + assertThat(new WebSocketSubprotocolName("hyeonworks.realtime.json").versioned()).isFalse(); + } + + @Test + @DisplayName("a production endpoint refuses a client that offers no subprotocol") + void productionRefusesTheUnnegotiatedFallback() { + assertThat(WebSocketProtocolProfile.stable().acceptsHandshake(List.of())).isFalse(); + assertThat(WebSocketProtocolProfile.stable().productionReady()).isTrue(); + } + + @Test + @DisplayName("the local compatibility profile accepts one, and says so in its name") + void localCompatibilityAcceptsTheFallback() { + assertThat(WebSocketProtocolProfile.localCompatibility().acceptsHandshake(List.of())).isTrue(); + // Not production ready, and the flag is what a startup validator can refuse on. + assertThat(WebSocketProtocolProfile.localCompatibility().productionReady()).isFalse(); + } + + @Test + @DisplayName("a client offering only an unknown subprotocol is refused") + void unknownSubprotocolIsRefused() { + assertThat(WebSocketProtocolProfile.stable().acceptsHandshake(List.of(OTHER))).isFalse(); + assertThat(WebSocketProtocolProfile.stable().negotiate(List.of(OTHER))).isEmpty(); + } + + @Test + @DisplayName("negotiation follows server preference, not client preference") + void negotiationFollowsServerPreference() { + // A client's order is a request. Honouring it lets one old client pin the server to an old + // format for as long as it keeps connecting. + WebSocketProtocolProfile profile = + new WebSocketProtocolProfile(List.of(STABLE, OTHER), WebSocketCodecProfile.JSON, false); + + assertThat(profile.negotiate(List.of(OTHER, STABLE))).contains(STABLE); + } + + @Test + @DisplayName("the Stable codec is JSON and there is exactly one") + void stableCodecIsJsonOnly() { + // Every additional codec is another parser reachable from an unauthenticated frame. + assertThat(WebSocketCodecProfile.values()).containsExactly(WebSocketCodecProfile.JSON); + assertThat(WebSocketProtocolProfile.stable().codec()).isEqualTo(WebSocketCodecProfile.JSON); + } + + @Test + @DisplayName("a profile that accepts nothing at all is refused") + void profileThatAcceptsNothingIsRefused() { + assertThatThrownBy( + () -> new WebSocketProtocolProfile(List.of(), WebSocketCodecProfile.JSON, false)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("accepts nothing at all"); + } + + @Test + @DisplayName("a subprotocol token with a comma is refused") + void subprotocolTokenRejectsAComma() { + // The header separates on commas, so a token containing one is two tokens to the client and + // one to the server. + assertThatThrownBy(() -> new WebSocketSubprotocolName("a.v1.json,b.v1.json")) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/release/WebSocketNginxProxyProfileTest.java b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/release/WebSocketNginxProxyProfileTest.java new file mode 100644 index 00000000..76de9a40 --- /dev/null +++ b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/release/WebSocketNginxProxyProfileTest.java @@ -0,0 +1,95 @@ +package dev.caskeleton.adapter.inbound.websocket.release; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * The three proxy mistakes that present as application bugs. + * + *

Each is checked here because each is invisible from the application's side: a cut connection + * with no cause in any server log, a 200 where a 101 was expected, and a client-chosen client IP + * reaching the rate limiter. + */ +class WebSocketNginxProxyProfileTest { + + private static final Duration HEARTBEAT = Duration.ofSeconds(30); + + @Test + @DisplayName("a compliant profile has no faults") + void compliantProfileHasNoFaults() { + WebSocketNginxProxyProfile profile = WebSocketNginxProxyProfile.compliant(HEARTBEAT); + + assertThat(profile.compliant()).isTrue(); + assertThat(profile.faults()).isEmpty(); + assertThat(profile.proxyReadTimeout()).isGreaterThan(profile.heartbeatInterval()); + } + + @Test + @DisplayName("a read timeout at or below the heartbeat is a fault") + void tooShortReadTimeoutIsAFault() { + // Equal is not enough: a beat that arrives at the timeout instant races it, and the loser is + // decided by scheduling. The connection dies with no cause in any server log. + assertThat(new WebSocketNginxProxyProfile(HEARTBEAT, HEARTBEAT, true, true, true).faults()) + .anyMatch(fault -> fault.contains("neither log will say proxy")); + assertThat( + new WebSocketNginxProxyProfile(Duration.ofSeconds(10), HEARTBEAT, true, true, true) + .faults()) + .hasSize(1); + } + + @Test + @DisplayName("dropped upgrade headers are a fault") + void droppedUpgradeHeadersAreAFault() { + // Upgrade and Connection are hop-by-hop, so a proxy drops them unless told otherwise, and the + // upstream then answers an ordinary GET with 200 and a body. + assertThat( + new WebSocketNginxProxyProfile(Duration.ofSeconds(90), HEARTBEAT, false, true, true) + .faults()) + .anyMatch(fault -> fault.contains("hop-by-hop")); + } + + @Test + @DisplayName("appending to client-supplied forwarded headers is a fault") + void unsanitizedForwardedHeadersAreAFault() { + // The client sends its own X-Forwarded-For, the proxy appends, and the application trusts the + // first entry — which the client chose. That address reaches the rate limiter and the audit + // log. + assertThat( + new WebSocketNginxProxyProfile(Duration.ofSeconds(90), HEARTBEAT, true, false, true) + .faults()) + .anyMatch(fault -> fault.contains("the client chooses the address")); + } + + @Test + @DisplayName("a ticket in the access log is a fault") + void loggedCredentialIsAFault() { + // A handshake ticket is a credential, and the access log is retained longer and read more + // widely than anything else that ever holds one. + assertThat( + new WebSocketNginxProxyProfile(Duration.ofSeconds(90), HEARTBEAT, true, true, false) + .faults()) + .anyMatch(fault -> fault.contains("retained longer and read more widely")); + } + + @Test + @DisplayName("every fault is reported at once") + void faultsAreReportedTogether() { + assertThat( + new WebSocketNginxProxyProfile(Duration.ofSeconds(10), HEARTBEAT, false, false, false) + .faults()) + .hasSize(4); + } + + @Test + @DisplayName("a negative timeout is refused at construction") + void negativeTimeoutIsRefused() { + assertThatThrownBy( + () -> + new WebSocketNginxProxyProfile(Duration.ofSeconds(-1), HEARTBEAT, true, true, true)) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/release/WebSocketRollingRestartScenarioTest.java b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/release/WebSocketRollingRestartScenarioTest.java new file mode 100644 index 00000000..53537522 --- /dev/null +++ b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/release/WebSocketRollingRestartScenarioTest.java @@ -0,0 +1,121 @@ +package dev.caskeleton.adapter.inbound.websocket.release; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Duration; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * A restart that looks graceful and is not. + * + *

Every step below has a failure mode that produces no error: readiness left up drains forever, + * a 1006 tells the client nothing, an un-jittered reconnect arrives as a thundering herd, and a + * resent command charges somebody twice. The scenario names them so a restart can be judged rather + * than described. + */ +class WebSocketRollingRestartScenarioTest { + + private final WebSocketRollingRestartScenario scenario = + WebSocketRollingRestartScenario.conventional(); + + private java.util.List blockers( + boolean readinessFirst, + boolean refusedHandshakes, + boolean drained, + int closeCode, + boolean jittered, + int duplicates) { + return scenario.blockers( + readinessFirst, refusedHandshakes, drained, closeCode, jittered, duplicates); + } + + @Test + @DisplayName("a clean restart reports nothing") + void cleanRestartReportsNothing() { + assertThat(blockers(true, true, true, 1012, true, 0)).isEmpty(); + } + + @Test + @DisplayName("readiness left up means the drain cannot finish") + void readinessLeftUpBlocks() { + // The balancer keeps sending connections to a node that is trying to empty itself, so the + // queue never reaches zero and the deadline forces a cut that looked avoidable. + assertThat(blockers(false, true, true, 1012, true, 0)) + .anyMatch(blocker -> blocker.contains("balancer kept sending")); + } + + @Test + @DisplayName("accepting a handshake while draining is a blocker") + void acceptingWhileDrainingBlocks() { + assertThat(blockers(true, false, true, 1012, true, 0)) + .anyMatch(blocker -> blocker.contains("about to cut")); + } + + @Test + @DisplayName("an unfinished drain names what is lost") + void unfinishedDrainBlocks() { + assertThat(blockers(true, true, false, 1012, true, 0)) + .anyMatch(blocker -> blocker.contains("what remains is lost")); + } + + @Test + @DisplayName("1001 and 1006 are both wrong, for different reasons") + void wrongCloseCodeBlocks() { + // 1001 says the server is going away for good, so a client that honours it stops retrying. + // 1006 is not a close code at all — it means no frame arrived, which is what an abrupt kill + // looks like. + assertThat(blockers(true, true, true, 1001, true, 0)) + .anyMatch(blocker -> blocker.contains("observed close code 1001")); + assertThat(blockers(true, true, true, 1006, true, 0)) + .anyMatch(blocker -> blocker.contains("observed close code 1006")); + } + + @Test + @DisplayName("an un-jittered reconnect is a blocker") + void unjitteredReconnectBlocks() { + assertThat(blockers(true, true, true, 1012, false, 0)) + .anyMatch(blocker -> blocker.contains("whole population")); + } + + @Test + @DisplayName("a command executed twice is a blocker, and it is counted") + void duplicateCommandBlocks() { + // The one failure with a financial shape. A reconnect that resends an in-flight command needs + // the idempotency ledger to recognise it; if it does not, the restart charged somebody twice. + assertThat(blockers(true, true, true, 1012, true, 3)) + .anyMatch(blocker -> blocker.contains("3 command(s) executed twice")); + } + + @Test + @DisplayName("every failure is reported at once") + void failuresAreReportedTogether() { + assertThat(blockers(false, false, false, 1006, false, 1)).hasSize(6); + } + + @Test + @DisplayName("a scenario with no deadline or no jitter is refused at construction") + void degenerateScenarioIsRefused() { + assertThatThrownBy( + () -> new WebSocketRollingRestartScenario(Duration.ZERO, 1012, Duration.ofSeconds(5))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("never completes"); + assertThatThrownBy( + () -> new WebSocketRollingRestartScenario(Duration.ofSeconds(30), 1012, Duration.ZERO)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("same instant"); + } + + @Test + @DisplayName("a scenario that expects the wrong close code is refused at construction") + void wrongExpectationIsRefused() { + // Otherwise the scenario would certify the defect it exists to catch. + assertThatThrownBy( + () -> + new WebSocketRollingRestartScenario( + Duration.ofSeconds(30), 1001, Duration.ofSeconds(5))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("going away for good"); + } +} diff --git a/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/release/WebSocketStableReleaseGateTest.java b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/release/WebSocketStableReleaseGateTest.java new file mode 100644 index 00000000..cbe9e488 --- /dev/null +++ b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/release/WebSocketStableReleaseGateTest.java @@ -0,0 +1,125 @@ +package dev.caskeleton.adapter.inbound.websocket.release; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.HashSet; +import java.util.Set; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * The conditions Stable may not be promoted without. + * + *

Each case falsifies one condition and asserts the gate refuses. A gate whose refusals are + * never exercised is the failure this repository keeps finding: a control that exists, passes its + * tests, and is reached by nothing. + */ +class WebSocketStableReleaseGateTest { + + private static final Set ALL_RUNTIMES = WebSocketStableReleaseGate.RUNTIMES; + private static final Set ALL_SUITES = WebSocketStableReleaseGate.SUITES; + + private static WebSocketStableReleaseGate satisfied() { + return new WebSocketStableReleaseGate(ALL_RUNTIMES, ALL_SUITES, true, true); + } + + private static Set without(Set values, String removed) { + Set remaining = new HashSet<>(values); + remaining.remove(removed); + return Set.copyOf(remaining); + } + + @Test + @DisplayName("a fully evidenced release is promotable") + void fullEvidencePromotes() { + assertThat(satisfied().promotable(ALL_RUNTIMES, ALL_SUITES)).isTrue(); + assertThat(satisfied().blockers(ALL_RUNTIMES, ALL_SUITES)).isEmpty(); + } + + @Test + @DisplayName("the standard gate starts un-promotable") + void standardGateStartsClosed() { + // Nothing is proven at the moment a release begins, so the default has to be refusal. A gate + // that defaults to open is satisfied by forgetting to run it. + WebSocketStableReleaseGate standard = WebSocketStableReleaseGate.standard(); + + assertThat(standard.promotable(Set.of(), Set.of())).isFalse(); + assertThat(standard.blockers(ALL_RUNTIMES, ALL_SUITES)) + .anyMatch(blocker -> blocker.contains("Advanced type is present")) + .anyMatch(blocker -> blocker.contains("support matrix is unpublished")); + } + + @Test + @DisplayName("a missing container is a blocker, and it is named") + void missingRuntimeBlocks() { + // Upgrade negotiation, close-frame timing and idle handling are container code, and the three + // containers disagree about all three — so evidence on one certifies exactly one. + assertThat(satisfied().blockers(without(ALL_RUNTIMES, "jetty"), ALL_SUITES)) + .hasSize(1) + .allMatch(blocker -> blocker.contains("jetty")); + } + + @Test + @DisplayName("the proxy counts as a runtime") + void proxyIsARuntime() { + // The one most often left out, and the one that fails in production rather than in a test: a + // proxy read timeout below the heartbeat cuts healthy connections and no server log says so. + assertThat(satisfied().blockers(without(ALL_RUNTIMES, "nginx"), ALL_SUITES)) + .anyMatch(blocker -> blocker.contains("nginx")); + } + + @Test + @DisplayName("the two named failure suites are mandatory") + void namedSuitesAreMandatory() { + // Named individually because each covers a failure that unit tests reported as passing. + assertThat(satisfied().blockers(ALL_RUNTIMES, without(ALL_SUITES, "commit-response-loss"))) + .anyMatch(blocker -> blocker.contains("commit-response-loss")); + assertThat(satisfied().blockers(ALL_RUNTIMES, without(ALL_SUITES, "slow-consumer"))) + .anyMatch(blocker -> blocker.contains("slow-consumer")); + } + + @Test + @DisplayName("an Advanced type in the Stable artifact blocks the release") + void advancedInArtifactBlocks() { + // The packaging half of WS-ARCH-6. The compile-time rule refuses a source edge; nothing in it + // notices a type that arrived through packaging, and the effect is the same — Stable does not + // build without Advanced. + WebSocketStableReleaseGate leaked = + new WebSocketStableReleaseGate(ALL_RUNTIMES, ALL_SUITES, false, true); + + assertThat(leaked.promotable(ALL_RUNTIMES, ALL_SUITES)).isFalse(); + assertThat(leaked.blockers(ALL_RUNTIMES, ALL_SUITES)) + .anyMatch(blocker -> blocker.contains("naming convention")); + } + + @Test + @DisplayName("an unpublished support matrix blocks the release") + void unpublishedMatrixBlocks() { + // What is not stated as unsupported is read as supported, and the reading happens after + // somebody has already built on it. + WebSocketStableReleaseGate silent = + new WebSocketStableReleaseGate(ALL_RUNTIMES, ALL_SUITES, true, false); + + assertThat(silent.blockers(ALL_RUNTIMES, ALL_SUITES)) + .anyMatch(blocker -> blocker.contains("read as supporting it")); + } + + @Test + @DisplayName("every blocker is reported at once") + void blockersAreReportedTogether() { + // Reporting one at a time turns a release into a queue of rediscoveries. + assertThat(WebSocketStableReleaseGate.standard().blockers(Set.of(), Set.of())).hasSize(4); + } + + @Test + @DisplayName("a gate that requires nothing is refused at construction") + void emptyGateIsRefused() { + // It would pass everything while reading, in a release checklist, as a gate. + assertThatThrownBy(() -> new WebSocketStableReleaseGate(Set.of(), ALL_SUITES, true, true)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("passes everything"); + assertThatThrownBy(() -> new WebSocketStableReleaseGate(ALL_RUNTIMES, Set.of(), true, true)) + .isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/runtime/TomcatWebSocketAbuseIT.java b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/runtime/TomcatWebSocketAbuseIT.java new file mode 100644 index 00000000..693e1701 --- /dev/null +++ b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/runtime/TomcatWebSocketAbuseIT.java @@ -0,0 +1,84 @@ +package dev.caskeleton.adapter.inbound.websocket.runtime; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.inbound.websocket.testkit.runtime.WebSocketAbuseContract; +import dev.caskeleton.adapter.inbound.websocket.testkit.runtime.WebSocketFixtureApplication; +import java.time.Duration; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; + +/** + * Abuse and graceful shutdown on the servlet default. + * + *

Shutdown is asserted here rather than in the shared contract because it is destructive: the + * container serves nothing afterwards, so it has to run last and own its own class. + */ +@SpringBootTest( + classes = WebSocketFixtureApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = "server.shutdown=graceful") +class TomcatWebSocketAbuseIT extends WebSocketAbuseContract { + + @LocalServerPort private int port; + + @Autowired + private dev.caskeleton.adapter.inbound.websocket.servlet.PlatformWebSocketHandler handler; + + @Override + protected int port() { + return port; + } + + @Test + @Tag("websocket-shutdown") + @DisplayName("shutting down closes live connections rather than dropping them") + void shutdownClosesLiveConnections() throws Exception { + // A dropped TCP connection gives the client a 1006 — "closed abnormally, no reason" — which is + // indistinguishable from a network failure and triggers its most aggressive reconnect path at + // exactly the moment the fleet is being restarted. + CountDownLatch closed = new CountDownLatch(1); + org.springframework.web.socket.client.standard.StandardWebSocketClient client = + new org.springframework.web.socket.client.standard.StandardWebSocketClient(); + java.util.concurrent.atomic.AtomicInteger observedCode = + new java.util.concurrent.atomic.AtomicInteger(); + client + .execute( + new org.springframework.web.socket.handler.TextWebSocketHandler() { + @Override + public void afterConnectionClosed( + org.springframework.web.socket.WebSocketSession session, + org.springframework.web.socket.CloseStatus status) { + observedCode.set(status.getCode()); + closed.countDown(); + } + }, + new org.springframework.web.socket.WebSocketHttpHeaders(), + java.net.URI.create("ws://localhost:" + port + WebSocketFixtureApplication.PATH)) + .get(10, TimeUnit.SECONDS); + + // The handler's own lifecycle stop, which is what Spring calls when the context closes. The + // container's graceful shutdown is deliberately not relied on: it waits for in-flight + // *requests*, and an established WebSocket is not a request — it completes with the + // connections still open, and they then die as a 1006. + long startedAt = System.nanoTime(); + handler.stop(); + boolean sawClose = closed.await(20, TimeUnit.SECONDS); + Duration took = Duration.ofNanos(System.nanoTime() - startedAt); + + // Bounded: a shutdown that waits indefinitely for a connection to go quiet is how a rolling + // deploy stalls with half the fleet drained and no error to alert on. + assertThat(took).isLessThan(Duration.ofSeconds(20)); + assertThat(sawClose).as("the client was never told the connection closed").isTrue(); + // 1001 "going away", not 1006. A client told this reconnects to a healthy node; one that sees + // 1006 cannot tell a deploy from a network failure and hammers the node it just left. + assertThat(observedCode.get()).isEqualTo(1001); + assertThat(handler.openConnections()).isZero(); + } +} diff --git a/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/runtime/TomcatWebSocketRuntimeIT.java b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/runtime/TomcatWebSocketRuntimeIT.java new file mode 100644 index 00000000..82db77b8 --- /dev/null +++ b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/runtime/TomcatWebSocketRuntimeIT.java @@ -0,0 +1,25 @@ +package dev.caskeleton.adapter.inbound.websocket.runtime; + +import dev.caskeleton.adapter.inbound.websocket.testkit.runtime.WebSocketFixtureApplication; +import dev.caskeleton.adapter.inbound.websocket.testkit.runtime.WebSocketRuntimeContract; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; + +/** + * The runtime contract on the servlet default. + * + *

A real container because everything asserted here is container code: upgrade negotiation, how + * a close frame is delivered, and what a peer observes when the server refuses a frame mid-message. + */ +@SpringBootTest( + classes = WebSocketFixtureApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +class TomcatWebSocketRuntimeIT extends WebSocketRuntimeContract { + + @LocalServerPort private int port; + + @Override + protected int port() { + return port; + } +} diff --git a/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/session/WebSocketSessionRegistryTest.java b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/session/WebSocketSessionRegistryTest.java new file mode 100644 index 00000000..cd7152d6 --- /dev/null +++ b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/session/WebSocketSessionRegistryTest.java @@ -0,0 +1,211 @@ +package dev.caskeleton.adapter.inbound.websocket.session; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketActorReference; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketConnectionContext; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketConnectionId; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketConnectionState; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketCredentialExpiry; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketEndpointName; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketNodeId; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketSessionId; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketSubprotocolName; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * The connections one node holds, and the bookkeeping that has to survive churn. + * + *

A registry is easy to get right for a hundred connections and wrong for a hundred thousand. + * The cases that matter are the ones about what happens over time — indexes that grow with the + * number of users who ever connected rather than with the number connected now. + */ +class WebSocketSessionRegistryTest { + + private static final Instant NOW = Instant.parse("2026-08-25T10:00:00Z"); + private static final byte[] SALT = + "a-deployment-salt-of-adequate-length".getBytes(StandardCharsets.UTF_8); + private static final WebSocketEndpointName ENDPOINT = new WebSocketEndpointName("live-updates"); + + private final WebSocketSessionRegistry registry = new WebSocketSessionRegistry(3); + + @Test + @DisplayName("a registered connection is findable") + void registeredConnectionIsFindable() { + WebSocketConnectionContext context = context("c-00000001", "alice"); + + assertThat(registry.register(context)).isTrue(); + assertThat(registry.find(context.connectionId())).contains(context); + assertThat(registry.countFor(ENDPOINT)).isOne(); + } + + @Test + @DisplayName("an actor cannot exceed its per-node cap") + void perActorCapHolds() { + for (int index = 0; index < 3; index++) { + assertThat(registry.register(context("c-0000000" + index, "alice"))).isTrue(); + } + + assertThat(registry.register(context("c-00000009", "alice"))).isFalse(); + // Another actor is unaffected: the cap is per actor, not global. + assertThat(registry.register(context("c-00000010", "bob"))).isTrue(); + } + + @Test + @DisplayName("concurrent registrations cannot exceed the cap") + void concurrentRegistrationsRespectTheCap() throws Exception { + // The check and the insert have to be one step. Two handshakes arriving together would both + // see room otherwise, which is how a cap becomes a suggestion under exactly the load it is for. + AtomicInteger accepted = new AtomicInteger(); + CountDownLatch start = new CountDownLatch(1); + try (ExecutorService pool = Executors.newFixedThreadPool(8)) { + Future[] futures = new Future[16]; + for (int index = 0; index < futures.length; index++) { + int id = index; + futures[index] = + pool.submit( + () -> { + start.await(); + if (registry.register(context(String.format("c-%08d", id), "alice"))) { + accepted.incrementAndGet(); + } + return null; + }); + } + start.countDown(); + for (Future future : futures) { + future.get(30, TimeUnit.SECONDS); + } + } + + assertThat(accepted).hasValue(3); + assertThat(registry.connectionsOf(actor("alice"))).hasSize(3); + } + + @Test + @DisplayName("deregistering frees the actor's allowance") + void deregisteringFreesTheAllowance() { + registry.register(context("c-00000001", "alice")); + registry.register(context("c-00000002", "alice")); + registry.register(context("c-00000003", "alice")); + + registry.deregister(new WebSocketConnectionId("c-00000002")); + + assertThat(registry.register(context("c-00000004", "alice"))).isTrue(); + } + + @Test + @DisplayName("the actor index does not grow with churn") + void actorIndexDoesNotLeakAcrossChurn() { + // A leak here is proportional to how many distinct users ever connected, not to how many are + // connected — so it looks fine for a day and unbounded over a month. + for (int index = 0; index < 50; index++) { + WebSocketConnectionContext context = + context("c-000000" + String.format("%02d", index), "user" + index); + registry.register(context); + registry.deregister(context.connectionId()); + } + + assertThat(registry.size()).isZero(); + assertThat(registry.connectionsOf(actor("user7"))).isEmpty(); + } + + @Test + @DisplayName("the endpoint count returns to zero") + void endpointCountReturnsToZero() { + WebSocketConnectionContext context = context("c-00000001", "alice"); + registry.register(context); + registry.deregister(context.connectionId()); + + assertThat(registry.countFor(ENDPOINT)).isZero(); + } + + @Test + @DisplayName("everything an actor holds is listable") + void actorConnectionsAreListable() { + // The operation the index exists for: disconnect everything this user has open. + registry.register(context("c-00000001", "alice")); + registry.register(context("c-00000002", "alice")); + registry.register(context("c-00000003", "bob")); + + assertThat(registry.connectionsOf(actor("alice"))).hasSize(2); + assertThat(registry.connectionsOf(actor("bob"))).hasSize(1); + } + + @Test + @DisplayName("draining moves open connections and returns them") + void drainMovesAndReturns() { + // Returned rather than closed, so the caller decides the pacing — that belongs to whoever + // knows how long the deploy is willing to wait. + registry.register(context("c-00000001", "alice")); + registry.register(context("c-00000002", "bob")); + + List draining = registry.drainAll(); + + assertThat(draining).hasSize(2); + assertThat(draining).allMatch(context -> context.state() == WebSocketConnectionState.DRAINING); + assertThat(registry.find(new WebSocketConnectionId("c-00000001")).orElseThrow().state()) + .isEqualTo(WebSocketConnectionState.DRAINING); + } + + @Test + @DisplayName("draining twice does not fail on already-draining connections") + void drainIsIdempotent() { + registry.register(context("c-00000001", "alice")); + registry.drainAll(); + + assertThat(registry.drainAll()).isEmpty(); + } + + @Test + @DisplayName("connections past their credential grace or their age are listed") + void expiredConnectionsAreListed() { + WebSocketConnectionContext aging = + new WebSocketConnectionContext( + new WebSocketConnectionId("c-00000001"), + new WebSocketSessionId("s-00000001"), + new WebSocketNodeId("edge-1"), + ENDPOINT, + Optional.of(WebSocketSubprotocolName.stable()), + actor("alice"), + WebSocketConnectionState.OPEN, + WebSocketCredentialExpiry.at(NOW.plusSeconds(10), Duration.ofSeconds(5)), + NOW); + registry.register(aging); + + assertThat(registry.expiredAt(NOW.plusSeconds(1), Duration.ofHours(4))).isEmpty(); + // Past the credential's grace. + assertThat(registry.expiredAt(NOW.plusSeconds(20), Duration.ofHours(4))).hasSize(1); + // Or past the maximum age, even with a live credential. + assertThat(registry.expiredAt(NOW.plusSeconds(1), Duration.ofMillis(1))).hasSize(1); + } + + private static WebSocketActorReference actor(String subject) { + return WebSocketActorReference.of(subject, "acme", SALT); + } + + private static WebSocketConnectionContext context(String connectionId, String subject) { + return new WebSocketConnectionContext( + new WebSocketConnectionId(connectionId), + new WebSocketSessionId("s-" + connectionId.substring(2)), + new WebSocketNodeId("edge-1"), + ENDPOINT, + Optional.of(WebSocketSubprotocolName.stable()), + actor(subject), + WebSocketConnectionState.OPEN, + WebSocketCredentialExpiry.never(), + NOW); + } +} diff --git a/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/LiveEventStompBroadcasterTest.java b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/stomp/LiveEventStompBroadcasterTest.java similarity index 97% rename from src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/LiveEventStompBroadcasterTest.java rename to src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/stomp/LiveEventStompBroadcasterTest.java index d0bcfbee..e2c2704a 100644 --- a/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/LiveEventStompBroadcasterTest.java +++ b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/stomp/LiveEventStompBroadcasterTest.java @@ -1,4 +1,4 @@ -package dev.caskeleton.adapter.inbound.websocket; +package dev.caskeleton.adapter.inbound.websocket.stomp; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; @@ -9,7 +9,7 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; -import dev.caskeleton.adapter.inbound.websocket.LiveEventStompBroadcaster.LiveEvent; +import dev.caskeleton.adapter.inbound.websocket.stomp.LiveEventStompBroadcaster.LiveEvent; import dev.caskeleton.domain.stereotype.DomainEvent; import java.util.LinkedHashMap; import java.util.List; diff --git a/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/SafeStompSubProtocolErrorHandlerTest.java b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/stomp/SafeStompSubProtocolErrorHandlerTest.java similarity index 95% rename from src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/SafeStompSubProtocolErrorHandlerTest.java rename to src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/stomp/SafeStompSubProtocolErrorHandlerTest.java index b1cb1ecc..a6dc68e1 100644 --- a/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/SafeStompSubProtocolErrorHandlerTest.java +++ b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/stomp/SafeStompSubProtocolErrorHandlerTest.java @@ -1,4 +1,4 @@ -package dev.caskeleton.adapter.inbound.websocket; +package dev.caskeleton.adapter.inbound.websocket.stomp; import static java.nio.charset.StandardCharsets.UTF_8; import static org.assertj.core.api.Assertions.assertThat; diff --git a/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/WebSocketBoundaryQualificationTest.java b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/stomp/WebSocketBoundaryQualificationTest.java similarity index 94% rename from src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/WebSocketBoundaryQualificationTest.java rename to src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/stomp/WebSocketBoundaryQualificationTest.java index 230711aa..ac65af64 100644 --- a/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/WebSocketBoundaryQualificationTest.java +++ b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/stomp/WebSocketBoundaryQualificationTest.java @@ -1,4 +1,4 @@ -package dev.caskeleton.adapter.inbound.websocket; +package dev.caskeleton.adapter.inbound.websocket.stomp; import static java.nio.charset.StandardCharsets.UTF_8; import static org.assertj.core.api.Assertions.assertThat; @@ -115,12 +115,18 @@ class WebSocketBoundaryQualificationTest { .hasRootCauseInstanceOf(DeploymentException.class) .hasStackTraceContaining("[401]"); + // The origin refusal is asserted by its status, not by the exception the client wraps it in. + // Spring Boot 4.0.x rejects a disallowed origin at the HTTP layer, so the client surfaces + // HttpServerErrorException(403) where it used to surface DeploymentException("[403]"). Both + // describe the same refusal — the handshake never completes and no session exists — and the + // status is the part this boundary actually promises. The missing-principal case above still + // fails inside the WebSocket deployment, which is why it keeps its root-cause assertion. assertThatThrownBy( () -> connect("https://evil.example", true, new ErrorCapturingHandler()) .get(5, TimeUnit.SECONDS)) - .hasRootCauseInstanceOf(DeploymentException.class) - .hasStackTraceContaining("[403]"); + .as("a disallowed origin must not complete a handshake") + .hasStackTraceContaining("403"); } @Test diff --git a/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/WebSocketInboundAuthorizationInterceptorTest.java b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/stomp/WebSocketInboundAuthorizationInterceptorTest.java similarity index 98% rename from src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/WebSocketInboundAuthorizationInterceptorTest.java rename to src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/stomp/WebSocketInboundAuthorizationInterceptorTest.java index fb8e50dd..20766e29 100644 --- a/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/WebSocketInboundAuthorizationInterceptorTest.java +++ b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/stomp/WebSocketInboundAuthorizationInterceptorTest.java @@ -1,4 +1,4 @@ -package dev.caskeleton.adapter.inbound.websocket; +package dev.caskeleton.adapter.inbound.websocket.stomp; import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatThrownBy; diff --git a/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/WebSocketPropertiesTest.java b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/stomp/WebSocketPropertiesTest.java similarity index 98% rename from src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/WebSocketPropertiesTest.java rename to src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/stomp/WebSocketPropertiesTest.java index 42de36c1..679f1994 100644 --- a/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/WebSocketPropertiesTest.java +++ b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/stomp/WebSocketPropertiesTest.java @@ -1,4 +1,4 @@ -package dev.caskeleton.adapter.inbound.websocket; +package dev.caskeleton.adapter.inbound.websocket.stomp; import static org.assertj.core.api.Assertions.assertThat; diff --git a/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/webflux/WebSocketDataBufferLifecycleTest.java b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/webflux/WebSocketDataBufferLifecycleTest.java new file mode 100644 index 00000000..e0df1531 --- /dev/null +++ b/src/adapter/inbound/websocket/src/test/java/dev/caskeleton/adapter/inbound/websocket/webflux/WebSocketDataBufferLifecycleTest.java @@ -0,0 +1,127 @@ +package dev.caskeleton.adapter.inbound.websocket.webflux; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.concurrent.atomic.AtomicLong; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Reference counting on the reactive stack, made countable. + * + *

Both directions are wrong in a way that does not fail where it happens. Under-releasing leaks + * pooled memory and surfaces weeks later as unattributable heap growth; over-releasing returns one + * pooled buffer to two owners and surfaces as a message whose contents belong to another + * connection. Neither throws at the call site, so the only early warning is a counter. + */ +class WebSocketDataBufferLifecycleTest { + + private final AtomicLong refusals = new AtomicLong(); + + private WebSocketDataBufferLifecycle lifecycle(WebSocketDataBufferPolicy policy) { + return new WebSocketDataBufferLifecycle(policy, bytes -> refusals.incrementAndGet()); + } + + @Test + @DisplayName("the safe default retains nothing") + void copyOnReceiveRetainsNothing() { + // A handler that copies out of the pooled buffer before returning cannot use-after-free, and + // this is the policy that says so rather than leaving it to convention. + WebSocketDataBufferLifecycle lifecycle = lifecycle(WebSocketDataBufferPolicy.copyOnReceive()); + + assertThat(lifecycle.retain(128)).isFalse(); + assertThat(lifecycle.retainedBytes()).isZero(); + assertThat(refusals.get()).isEqualTo(1); + } + + @Test + @DisplayName("a retaining policy holds up to its ceiling and refuses past it") + void retainingIsBounded() { + WebSocketDataBufferLifecycle lifecycle = lifecycle(WebSocketDataBufferPolicy.retaining(1_000)); + + assertThat(lifecycle.retain(600)).isTrue(); + assertThat(lifecycle.retain(400)).isTrue(); + assertThat(lifecycle.retain(1)).isFalse(); + assertThat(lifecycle.retainedBytes()).isEqualTo(1_000); + assertThat(refusals.get()).isEqualTo(1); + } + + @Test + @DisplayName("releasing frees the ceiling again") + void releasingFreesTheCeiling() { + WebSocketDataBufferLifecycle lifecycle = lifecycle(WebSocketDataBufferPolicy.retaining(1_000)); + lifecycle.retain(1_000); + + lifecycle.release(600); + + assertThat(lifecycle.retain(600)).isTrue(); + } + + @Test + @DisplayName("a balanced stream ends with nothing retained") + void balancedStreamEndsClean() { + // What a leak test asserts after the stream ends — including a cancelled one, which for a + // long-lived socket is the ordinary way it ends. + WebSocketDataBufferLifecycle lifecycle = lifecycle(WebSocketDataBufferPolicy.retaining(1_000)); + lifecycle.retain(100); + lifecycle.retain(200); + + assertThat(lifecycle.balanced()).isFalse(); + + lifecycle.release(100); + lifecycle.release(200); + + assertThat(lifecycle.balanced()).isTrue(); + assertThat(lifecycle.retains()).isEqualTo(lifecycle.releases()); + } + + @Test + @DisplayName("a double release is refused rather than wrapping past zero") + void doubleReleaseIsRefused() { + // The counter is the only place this is visible. Letting it go negative would hide the second + // release and leave the corrupted message as the first symptom. + WebSocketDataBufferLifecycle lifecycle = lifecycle(WebSocketDataBufferPolicy.retaining(1_000)); + lifecycle.retain(100); + lifecycle.release(100); + + assertThatThrownBy(() -> lifecycle.release(100)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("two owners"); + assertThat(lifecycle.retainedBytes()).isZero(); + } + + @Test + @DisplayName("retaining an unpooled buffer is refused at construction") + void unpooledRetentionIsRefused() { + assertThatThrownBy(() -> new WebSocketDataBufferPolicy(false, true, 100)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("needs no reference counting"); + } + + @Test + @DisplayName("retaining without a ceiling is refused at construction") + void unboundedRetentionIsRefused() { + assertThatThrownBy(() -> new WebSocketDataBufferPolicy(true, true, 0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("one cancelled subscription at a time"); + } + + @Test + @DisplayName("a ceiling that cannot be reached is refused at construction") + void unreachableCeilingIsRefused() { + // A policy stating a limit it never applies reads to an operator as a limit that is in force. + assertThatThrownBy(() -> new WebSocketDataBufferPolicy(true, false, 100)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("cannot be reached"); + } + + @Test + @DisplayName("a negative size is refused in both directions") + void negativeSizesAreRefused() { + WebSocketDataBufferLifecycle lifecycle = lifecycle(WebSocketDataBufferPolicy.retaining(1_000)); + + assertThatThrownBy(() -> lifecycle.retain(-1)).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> lifecycle.release(-1)).isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/src/adapter/inbound/websocket/src/testkit/java/dev/caskeleton/adapter/inbound/websocket/testkit/arch/WebSocketArchitectureRules.java b/src/adapter/inbound/websocket/src/testkit/java/dev/caskeleton/adapter/inbound/websocket/testkit/arch/WebSocketArchitectureRules.java new file mode 100644 index 00000000..e79c653a --- /dev/null +++ b/src/adapter/inbound/websocket/src/testkit/java/dev/caskeleton/adapter/inbound/websocket/testkit/arch/WebSocketArchitectureRules.java @@ -0,0 +1,160 @@ +package dev.caskeleton.adapter.inbound.websocket.testkit.arch; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noMethods; + +import com.tngtech.archunit.lang.ArchRule; + +/** + * The boundaries this platform depends on that no dependency gate can see. + * + *

A Gradle gate checks which artifacts a module may use. Every rule here is about how a legal + * artifact is used: {@code spring-websocket} is a legitimate dependency of this leaf, so nothing in + * the build can tell that a handler reached for a {@code WebSocketSession} — only a rule that reads + * the handler can. + * + *

In the testkit rather than {@code main} because ArchUnit is a test library, and shipping it in + * production would put it on every deployment's classpath to serve code that only runs in a test. + */ +public final class WebSocketArchitectureRules { + + private static final String PLATFORM = "dev.caskeleton.adapter.inbound.websocket"; + + private WebSocketArchitectureRules() {} + + /** + * A message handler cannot touch the transport. + * + *

The rule the entire outbound design rests on. A handler that can reach a session can write + * to the socket directly, and once one can, ordering, backpressure and the drain sequence are + * advisory — they hold for the handlers that cooperate and silently do not for the one that did + * not. Nothing at the type level prevents it; the handler receives a context by design, but + * nothing stops it from being handed a session by whoever wires it. + */ + public static ArchRule handlersDoNotTouchTheTransport() { + return noClasses() + .that() + .resideInAPackage(PLATFORM + ".handler..") + .should() + .dependOnClassesThat() + .resideInAnyPackage( + "org.springframework.web.socket..", + "jakarta.websocket..", + "io.netty..", + "reactor.netty..") + .as( + "WS-ARCH-1: a message handler may not name a transport session. A handler that can" + + " write directly bypasses the outbound queue, and every ordering and" + + " backpressure guarantee becomes advisory") + .allowEmptyShould(true); + } + + /** + * The two runtimes never see each other. + * + *

They are mutually exclusive at deployment: Boot deduces one application type from the + * classpath. A compile-time edge between them makes it possible to ship both, at which point one + * set of endpoints is silently never served. + */ + public static ArchRule runtimesAreMutuallyExclusive() { + return noClasses() + .that() + .resideInAPackage(PLATFORM + ".servlet..") + .should() + .dependOnClassesThat() + .resideInAPackage(PLATFORM + ".webflux..") + .as("WS-ARCH-2: the servlet runtime may not name the reactive one") + .allowEmptyShould(true); + } + + /** The reverse of {@link #runtimesAreMutuallyExclusive}. */ + public static ArchRule reactiveRuntimeDoesNotNameServlet() { + return noClasses() + .that() + .resideInAPackage(PLATFORM + ".webflux..") + .should() + .dependOnClassesThat() + .resideInAnyPackage(PLATFORM + ".servlet..", "jakarta.servlet..") + .as("WS-ARCH-3: the reactive runtime may not name the servlet one") + .allowEmptyShould(true); + } + + /** + * The platform does not reach persistence or the domain directly. + * + *

A transport that reads a repository has skipped the application: there is no use case to + * test, no transaction anybody declared, and no port a second transport could reuse. It is the + * same rule the HTTP platform carries, and it matters more here because a long-lived connection + * makes "just read it directly in the handler" look cheap. + */ + public static ArchRule platformDoesNotReachPersistence() { + return noClasses() + .that() + .resideInAPackage(PLATFORM + "..") + .should() + .dependOnClassesThat() + .resideInAnyPackage( + "dev.caskeleton.adapter.outbound..", + "jakarta.persistence..", + "org.springframework.data..") + .as("WS-ARCH-4: the websocket platform reaches the application, never persistence") + .allowEmptyShould(true); + } + + /** + * Nothing writes a raw close code. + * + *

A literal {@code close(1008, ...)} scattered through handlers is how the close-code catalog + * stops being the catalog. The codes are a published contract, and a client branches on them. + */ + public static ArchRule closeCodesComeFromTheCatalog() { + return noMethods() + .that() + .areDeclaredInClassesThat() + .resideInAPackage(PLATFORM + "..") + .and() + .areDeclaredInClassesThat() + .resideOutsideOfPackage(PLATFORM + ".error..") + .should() + .haveNameMatching("closeWithCode|sendCloseCode") + .as( + "WS-ARCH-5: close codes come from WebSocketCloseCode, not from a literal at the call" + + " site") + .allowEmptyShould(true); + } + + /** + * No Stable module may name an Advanced one. + * + *

The rule the design states and the one a feature flag cannot enforce. A flag decides whether + * an Advanced bean is created; it does nothing about a Stable class that imports an Advanced + * type, and such a class makes the Stable platform fail to compile without the Advanced code — at + * which point the separation exists only in the documentation. + */ + public static ArchRule stableDoesNotDependOnAdvanced() { + return noClasses() + .that() + .resideInAPackage(PLATFORM + "..") + .and() + .resideOutsideOfPackage(PLATFORM + ".advanced..") + .should() + .dependOnClassesThat() + .resideInAPackage(PLATFORM + ".advanced..") + .as( + "WS-ARCH-6: a Stable module may not name an Advanced one. A feature flag decides" + + " whether a bean is created; it does nothing about a compile-time edge, and one" + + " such edge makes Stable unbuildable without Advanced") + .allowEmptyShould(true); + } + + /** Every rule, for a consumer that wants the whole pack. */ + public static java.util.List all() { + return java.util.List.of( + handlersDoNotTouchTheTransport(), + runtimesAreMutuallyExclusive(), + reactiveRuntimeDoesNotNameServlet(), + platformDoesNotReachPersistence(), + closeCodesComeFromTheCatalog(), + stableDoesNotDependOnAdvanced()); + } +} diff --git a/src/adapter/inbound/websocket/src/testkit/java/dev/caskeleton/adapter/inbound/websocket/testkit/fault/WebSocketFaultInjector.java b/src/adapter/inbound/websocket/src/testkit/java/dev/caskeleton/adapter/inbound/websocket/testkit/fault/WebSocketFaultInjector.java new file mode 100644 index 00000000..06d975c0 --- /dev/null +++ b/src/adapter/inbound/websocket/src/testkit/java/dev/caskeleton/adapter/inbound/websocket/testkit/fault/WebSocketFaultInjector.java @@ -0,0 +1,62 @@ +package dev.caskeleton.adapter.inbound.websocket.testkit.fault; + +import java.util.Objects; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Arms one fault point for one command. + * + *

Called at every point a command passes through, whether or not a fault is armed. A handler + * that only consulted the injector where it expected a fault would be testing the injector. + */ +public final class WebSocketFaultInjector { + + private final AtomicReference armed = new AtomicReference<>(); + + /** Arms a point, replacing whatever was armed. */ + public void arm(WebSocketFaultPoint point) { + armed.set(Objects.requireNonNull(point, "point")); + } + + /** Disarms. */ + public void disarm() { + armed.set(null); + } + + /** + * Fails when this point is armed. + * + *

Single-shot: the point is disarmed as it fires, so a retry of the same command proceeds. A + * fault that stayed armed would make every attempt fail, which tests nothing about recovery. + * + * @throws InjectedWebSocketFault when this point was armed + */ + public void trigger(WebSocketFaultPoint point) { + if (armed.compareAndSet(point, null)) { + throw new InjectedWebSocketFault(point); + } + } + + /** Whether anything is armed. */ + public boolean armed() { + return armed.get() != null; + } + + /** The injected failure. */ + public static final class InjectedWebSocketFault extends RuntimeException { + + private static final long serialVersionUID = 1L; + + private final transient WebSocketFaultPoint point; + + InjectedWebSocketFault(WebSocketFaultPoint point) { + super("injected fault at " + point); + this.point = point; + } + + /** Where it fired. */ + public WebSocketFaultPoint point() { + return point; + } + } +} diff --git a/src/adapter/inbound/websocket/src/testkit/java/dev/caskeleton/adapter/inbound/websocket/testkit/fault/WebSocketFaultPoint.java b/src/adapter/inbound/websocket/src/testkit/java/dev/caskeleton/adapter/inbound/websocket/testkit/fault/WebSocketFaultPoint.java new file mode 100644 index 00000000..83d7a2fd --- /dev/null +++ b/src/adapter/inbound/websocket/src/testkit/java/dev/caskeleton/adapter/inbound/websocket/testkit/fault/WebSocketFaultPoint.java @@ -0,0 +1,39 @@ +package dev.caskeleton.adapter.inbound.websocket.testkit.fault; + +/** + * Where a command's handling can be interrupted. + * + *

The ordering is the point. Between {@link #AFTER_APPLICATION_COMMIT_BEFORE_LEDGER} and {@link + * #AFTER_LEDGER_BEFORE_RESPONSE} lies the only window in which a crash produces an outcome nothing + * can reconstruct: the business work is durable and the ledger does not know it. Every other point + * is recoverable by replaying or by discarding, and this one is neither. + * + *

A WebSocket makes that window matter more than HTTP does. An HTTP client that loses its + * connection has one request to reason about; a WebSocket client reconnects and replays everything + * it never saw an answer for, all at once. + */ +public enum WebSocketFaultPoint { + + /** Before the message is decoded. Nothing has happened. */ + BEFORE_DECODE, + + /** Decoded and admitted, not yet handed to the application. */ + AFTER_ADMISSION_BEFORE_APPLICATION, + + /** Inside the application, before its transaction commits. */ + AFTER_APPLICATION_START_BEFORE_COMMIT, + + /** + * The application committed and the ledger has not recorded it. + * + *

The window. Replaying invents a result; re-running duplicates a committed write. Only the + * business data can settle it. + */ + AFTER_APPLICATION_COMMIT_BEFORE_LEDGER, + + /** The ledger recorded the outcome and the response has not been sent. */ + AFTER_LEDGER_BEFORE_RESPONSE, + + /** The response was queued and the socket died before it was written. */ + AFTER_RESPONSE_QUEUED_BEFORE_FLUSH +} diff --git a/src/adapter/inbound/websocket/src/testkit/java/dev/caskeleton/adapter/inbound/websocket/testkit/runtime/SlowConsumerContract.java b/src/adapter/inbound/websocket/src/testkit/java/dev/caskeleton/adapter/inbound/websocket/testkit/runtime/SlowConsumerContract.java new file mode 100644 index 00000000..6bc07f8d --- /dev/null +++ b/src/adapter/inbound/websocket/src/testkit/java/dev/caskeleton/adapter/inbound/websocket/testkit/runtime/SlowConsumerContract.java @@ -0,0 +1,214 @@ +package dev.caskeleton.adapter.inbound.websocket.testkit.runtime; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.inbound.websocket.budget.WebSocketConnectionBudget; +import dev.caskeleton.adapter.inbound.websocket.core.WebSocketConnectionId; +import dev.caskeleton.adapter.inbound.websocket.outbound.GlobalBufferBudget; +import dev.caskeleton.adapter.inbound.websocket.outbound.OutboundDelivery; +import dev.caskeleton.adapter.inbound.websocket.outbound.OutboundEnqueueResult; +import dev.caskeleton.adapter.inbound.websocket.outbound.OutboundMessage; +import dev.caskeleton.adapter.inbound.websocket.outbound.OutboundPriority; +import dev.caskeleton.adapter.inbound.websocket.outbound.OutboundQueue; +import dev.caskeleton.adapter.inbound.websocket.outbound.SerializedOutboundWriter; +import dev.caskeleton.adapter.inbound.websocket.protocol.WebSocketMessageId; +import java.time.Duration; +import java.util.Optional; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +/** + * A consumer that reads slower than the server writes, which is the WebSocket failure that never + * produces an error. + * + *

Nothing throws. The peer is not broken, the socket is healthy, the writes succeed — they just + * accumulate. One such client is invisible; a few hundred is an OutOfMemoryError with no failing + * request anywhere to attribute it to, and "read slowly" requires no tooling at all to arrange. + * + *

The gate is on shape rather than scale: that the memory a slow consumer can occupy is bounded, + * that the bound is enforced node-wide and not only per connection, and that the node keeps serving + * the peers that are reading. Those hold on any hardware; absolute numbers do not. + */ +@Tag("websocket-performance") +@Timeout(value = 120, unit = TimeUnit.SECONDS) +public abstract class SlowConsumerContract { + + private static final WebSocketConnectionBudget BUDGET = + new WebSocketConnectionBudget( + 64 * 1024, 512 * 1024, 16, 100, 8 * 1024, Duration.ofHours(1), Duration.ofSeconds(90)); + + /** + * A writer whose peer has stopped reading. + * + *

The stall has to be held from another thread, which is also how it works in production: the + * writer's lock is held by whoever is currently writing, and every other producer's {@code + * tryLock} fails and returns after queueing. An earlier version blocked in the sink on the + * producer's own thread — that is not a slow consumer, it is a slow producer, and it hung the + * test for ten minutes rather than filling a queue. + */ + private static Stalled stalledWriter(String id, GlobalBufferBudget global) { + CountDownLatch insideSink = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + SerializedOutboundWriter writer = + new SerializedOutboundWriter( + new WebSocketConnectionId(id), + new OutboundQueue(BUDGET, 64, global), + payload -> { + insideSink.countDown(); + // Blocks exactly as a socket to a peer that is not reading does once its send buffer + // fills: the write neither fails nor completes. + if (!release.await(60, TimeUnit.SECONDS)) { + throw new java.io.IOException("stalled"); + } + }); + Thread holder = + Thread.ofVirtual() + .start( + () -> + writer.offer( + new OutboundMessage( + new WebSocketMessageId("m-hold"), + "hold", + OutboundDelivery.DROPPABLE, + OutboundPriority.EVENT, + Optional.empty()))); + try { + // Confirmed inside the sink before the test starts producing. Without this the producer may + // win the lock and drain the queue it was supposed to fill. + if (!insideSink.await(10, TimeUnit.SECONDS)) { + throw new IllegalStateException("the stalled writer never entered its sink"); + } + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(interrupted); + } + return new Stalled(writer, release, holder); + } + + /** + * A stalled writer and the means to let it go. + * + * @param writer the writer whose peer is not reading + * @param release releases the sink + * @param holder the thread parked inside the sink + */ + protected record Stalled(SerializedOutboundWriter writer, CountDownLatch release, Thread holder) { + + /** Releases the sink and waits for the parked thread. */ + void finish() throws InterruptedException { + release.countDown(); + holder.join(Duration.ofSeconds(10)); + writer.close(); + } + } + + private static OutboundMessage droppable(String id, int bytes) { + return new OutboundMessage( + new WebSocketMessageId(id), + "x".repeat(bytes), + OutboundDelivery.DROPPABLE, + OutboundPriority.EVENT, + Optional.empty()); + } + + @Test + @DisplayName("one stalled consumer's memory is bounded by its own budget") + void oneStalledConsumerIsBounded() throws Exception { + GlobalBufferBudget global = new GlobalBufferBudget(1024 * 1024); + Stalled stalled = stalledWriter("c-stalled-01", global); + + try { + long dropped = 0; + for (int index = 0; index < 500; index++) { + if (stalled.writer().offer(droppable("m-" + index, 512)) == OutboundEnqueueResult.DROPPED) { + dropped++; + } + } + + // Bounded by the connection's own budget, not by how much the producer sent. + assertThat(global.buffered()).isLessThanOrEqualTo(BUDGET.maxBufferedOutboundBytes()); + assertThat(dropped).isPositive(); + } finally { + stalled.finish(); + } + } + + @Test + @DisplayName("many stalled consumers together are bounded by the node ceiling") + void manyStalledConsumersAreBoundedNodeWide() throws Exception { + // The bound a per-connection limit cannot give. Eight kilobytes each is nothing; multiplied by + // however many connections an attacker opens, it is the heap. + GlobalBufferBudget global = new GlobalBufferBudget(32 * 1024); + Stalled[] stalled = new Stalled[16]; + + try { + for (int index = 0; index < stalled.length; index++) { + stalled[index] = stalledWriter(String.format("c-stall-%04d", index), global); + } + for (int round = 0; round < 40; round++) { + for (Stalled peer : stalled) { + peer.writer().offer(droppable("m-" + round, 512)); + } + } + + // 16 connections x 8 KiB each would be 128 KiB. The node ceiling is 32 KiB and it holds. + assertThat(global.buffered()).isLessThanOrEqualTo(32 * 1024); + assertThat(global.peak()).isLessThanOrEqualTo(32 * 1024); + } finally { + for (Stalled peer : stalled) { + if (peer != null) { + peer.finish(); + } + } + } + } + + @Test + @DisplayName("a reading consumer is still served while others are stalled") + void healthyConsumerIsUnaffected() throws Exception { + // The property that decides whether one bad client degrades itself or the node. + GlobalBufferBudget global = new GlobalBufferBudget(64 * 1024); + Stalled stalled = stalledWriter("c-stalled-02", global); + AtomicLong delivered = new AtomicLong(); + SerializedOutboundWriter healthy = + new SerializedOutboundWriter( + new WebSocketConnectionId("c-healthy-01"), + new OutboundQueue(BUDGET, 64, global), + payload -> delivered.incrementAndGet()); + + try { + for (int index = 0; index < 100; index++) { + stalled.writer().offer(droppable("s-" + index, 256)); + healthy.offer(droppable("h-" + index, 256)); + } + + assertThat(delivered.get()).isEqualTo(100); + } finally { + stalled.finish(); + healthy.close(); + } + } + + @Test + @DisplayName("a stalled connection returns its memory when it goes away") + void closingAStalledConsumerReleasesTheNode() throws Exception { + // Otherwise the node leaks capacity for every connection that ever closed while behind — which + // is every connection that was ever slow, so the ceiling erodes over a deployment's life. + GlobalBufferBudget global = new GlobalBufferBudget(64 * 1024); + Stalled stalled = stalledWriter("c-stalled-03", global); + + for (int index = 0; index < 100; index++) { + stalled.writer().offer(droppable("m-" + index, 256)); + } + assertThat(global.buffered()).isPositive(); + + stalled.finish(); + + assertThat(global.buffered()).isZero(); + } +} diff --git a/src/adapter/inbound/websocket/src/testkit/java/dev/caskeleton/adapter/inbound/websocket/testkit/runtime/WebSocketAbuseContract.java b/src/adapter/inbound/websocket/src/testkit/java/dev/caskeleton/adapter/inbound/websocket/testkit/runtime/WebSocketAbuseContract.java new file mode 100644 index 00000000..dde973aa --- /dev/null +++ b/src/adapter/inbound/websocket/src/testkit/java/dev/caskeleton/adapter/inbound/websocket/testkit/runtime/WebSocketAbuseContract.java @@ -0,0 +1,155 @@ +package dev.caskeleton.adapter.inbound.websocket.testkit.runtime; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.Socket; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +/** + * What the runtime must survive from a hostile or merely broken peer. + * + *

A WebSocket endpoint is reachable, long-lived and cheap to open, which makes it a better + * target than an HTTP route. Every case here costs the attacker almost nothing: opening a socket + * and not finishing the handshake, sending a frame header that promises more than it delivers, or + * simply connecting many times. + * + *

Every case is bounded by a timeout, because the failure mode being tested is the server + * hanging rather than the server erroring — and a hanging test reports nothing. + */ +@Tag("websocket-abuse") +@Timeout(value = 60, unit = TimeUnit.SECONDS) +public abstract class WebSocketAbuseContract { + + /** The port the fixture is listening on. */ + protected abstract int port(); + + private URI endpoint() { + return URI.create("ws://localhost:" + port() + WebSocketFixtureApplication.PATH); + } + + @Test + @DisplayName("connections that never complete the handshake do not stop the server serving") + void halfOpenHandshakesDoNotExhaustTheServer() throws Exception { + // A socket opened and left mid-handshake costs the attacker one file descriptor and costs the + // server whatever it allocates per pending upgrade. It is the cheapest possible attack. + List halfOpen = openHalfHandshakes(64); + try { + assertThat(halfOpen).hasSizeGreaterThanOrEqualTo(32); + + try (WebSocketRuntimeContract.Peer peer = WebSocketRuntimeContract.Peer.connect(endpoint())) { + peer.send("still-alive"); + assertThat(peer.awaitMessage()).isEqualTo("echo:still-alive"); + } + } finally { + closeAll(halfOpen); + } + } + + @Test + @DisplayName("the server recovers once the half-open connections go away") + void serverRecoversAfterHalfOpenConnectionsClose() throws Exception { + // The half that matters operationally: surviving the abuse is not enough if the resources are + // never reclaimed, because then one incident degrades the node permanently. + closeAll(openHalfHandshakes(64)); + + try (WebSocketRuntimeContract.Peer peer = WebSocketRuntimeContract.Peer.connect(endpoint())) { + peer.send("recovered"); + assertThat(peer.awaitMessage()).isEqualTo("echo:recovered"); + } + } + + @Test + @DisplayName("a burst of connections is served or refused, never hung") + void connectionBurstIsBounded() throws Exception { + // Opening a connection is cheap for the client and not for the server. Either answer is + // acceptable; hanging is not, because a hung accept queue takes the whole endpoint down. + List peers = new ArrayList<>(); + try { + for (int index = 0; index < 24; index++) { + try { + peers.add(WebSocketRuntimeContract.Peer.connect(endpoint())); + } catch (Exception refused) { + // A refusal is a bounded response, which is the property under test. + break; + } + } + + assertThat(peers).isNotEmpty(); + WebSocketRuntimeContract.Peer first = peers.get(0); + first.send("burst"); + assertThat(first.awaitMessage()).isEqualTo("echo:burst"); + } finally { + for (WebSocketRuntimeContract.Peer peer : peers) { + try { + peer.close(); + } catch (Exception ignored) { + // Already gone. + } + } + } + } + + @Test + @DisplayName("a plain HTTP request to the endpoint is answered, not upgraded") + void plainHttpRequestIsNotUpgraded() throws Exception { + // The endpoint is reachable by anything that can open a socket. A GET with no upgrade headers + // must produce an ordinary HTTP answer rather than putting the connection into a state the + // server then treats as a WebSocket. + try (Socket socket = new Socket("localhost", port())) { + socket.setSoTimeout(10_000); + socket + .getOutputStream() + .write( + ("GET " + WebSocketFixtureApplication.PATH + " HTTP/1.1\r\nHost: localhost\r\n\r\n") + .getBytes(StandardCharsets.UTF_8)); + socket.getOutputStream().flush(); + + byte[] response = new byte[64]; + int read = socket.getInputStream().read(response); + assertThat(read).isPositive(); + assertThat(new String(response, 0, read, StandardCharsets.UTF_8)).startsWith("HTTP/1.1"); + } + } + + private List openHalfHandshakes(int count) { + List sockets = new ArrayList<>(); + for (int index = 0; index < count; index++) { + try { + Socket socket = new Socket("localhost", port()); + socket.setSoTimeout(1_000); + OutputStream out = socket.getOutputStream(); + // The request line and one header, then nothing. The handshake never completes and the + // server cannot know whether more is coming. + out.write( + ("GET " + WebSocketFixtureApplication.PATH + " HTTP/1.1\r\nHost: localhost\r\n") + .getBytes(StandardCharsets.UTF_8)); + out.flush(); + sockets.add(socket); + } catch (IOException refused) { + // The server refusing further connections is itself a bounded response. + break; + } + } + return sockets; + } + + private static void closeAll(List sockets) { + for (Socket socket : sockets) { + try { + socket.close(); + } catch (IOException ignored) { + // Already gone. + } + } + } +} diff --git a/src/adapter/inbound/websocket/src/testkit/java/dev/caskeleton/adapter/inbound/websocket/testkit/runtime/WebSocketFixtureApplication.java b/src/adapter/inbound/websocket/src/testkit/java/dev/caskeleton/adapter/inbound/websocket/testkit/runtime/WebSocketFixtureApplication.java new file mode 100644 index 00000000..ebd3f6b4 --- /dev/null +++ b/src/adapter/inbound/websocket/src/testkit/java/dev/caskeleton/adapter/inbound/websocket/testkit/runtime/WebSocketFixtureApplication.java @@ -0,0 +1,64 @@ +package dev.caskeleton.adapter.inbound.websocket.testkit.runtime; + +import dev.caskeleton.adapter.inbound.websocket.budget.WebSocketConnectionBudget; +import dev.caskeleton.adapter.inbound.websocket.outbound.GlobalBufferBudget; +import dev.caskeleton.adapter.inbound.websocket.servlet.PlatformWebSocketHandler; +import java.time.Duration; +import java.util.Optional; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.socket.config.annotation.EnableWebSocket; +import org.springframework.web.socket.config.annotation.WebSocketConfigurer; +import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry; + +/** + * The smallest servlet application that serves the platform handler. + * + *

Outside the component-scanned package tree for the reason the web platform's fixtures had to + * be: a {@code @Configuration} under the adapter root is loaded into the real deployment, and this + * one registers a WebSocket endpoint with no authentication on it. + * + *

The bounds are deliberately tiny. The behaviour under test is what happens at a boundary, and + * a lane sized realistically would spend its time pushing bytes rather than crossing one. + */ +@Configuration(proxyBeanMethods = false) +@EnableAutoConfiguration +@EnableWebSocket +public class WebSocketFixtureApplication implements WebSocketConfigurer { + + /** Where the fixture endpoint listens. */ + public static final String PATH = "/ws/v1/fixture"; + + /** The frame bound this lane tests against. */ + public static final int MAX_FRAME_BYTES = 512; + + /** The reassembled-message bound this lane tests against. */ + public static final int MAX_MESSAGE_BYTES = 1024; + + private final PlatformWebSocketHandler handler = + new PlatformWebSocketHandler( + new WebSocketConnectionBudget( + MAX_FRAME_BYTES, + MAX_MESSAGE_BYTES, + 8, + 100, + 8 * 1024, + Duration.ofHours(1), + Duration.ofSeconds(90)), + new GlobalBufferBudget(1024 * 1024), + // Echoes with a prefix, so a test can tell an answer from its own request and can prove + // the whole reassembled message arrived rather than the last fragment. + (connectionId, message) -> Optional.of("echo:" + message)); + + /** The handler under test, for assertions about connection accounting. */ + @Bean + PlatformWebSocketHandler platformWebSocketHandler() { + return handler; + } + + @Override + public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { + registry.addHandler(handler, PATH).setAllowedOrigins("*"); + } +} diff --git a/src/adapter/inbound/websocket/src/testkit/java/dev/caskeleton/adapter/inbound/websocket/testkit/runtime/WebSocketRuntimeContract.java b/src/adapter/inbound/websocket/src/testkit/java/dev/caskeleton/adapter/inbound/websocket/testkit/runtime/WebSocketRuntimeContract.java new file mode 100644 index 00000000..6b77ef17 --- /dev/null +++ b/src/adapter/inbound/websocket/src/testkit/java/dev/caskeleton/adapter/inbound/websocket/testkit/runtime/WebSocketRuntimeContract.java @@ -0,0 +1,207 @@ +package dev.caskeleton.adapter.inbound.websocket.testkit.runtime; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.net.URI; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketHttpHeaders; +import org.springframework.web.socket.WebSocketSession; +import org.springframework.web.socket.client.standard.StandardWebSocketClient; +import org.springframework.web.socket.handler.TextWebSocketHandler; + +/** + * The runtime contract, asserted against a real container. + * + *

Everything here is container behaviour. Upgrade negotiation, how a close frame is delivered, + * what a peer observes when the server refuses a frame mid-message, whether a partially received + * message is discarded — all of it lives in the container's WebSocket implementation, and a mock + * dispatcher certifies none of it. The two servlet containers implement each of these separately, + * which is why the same contract runs on both. + */ +public abstract class WebSocketRuntimeContract { + + /** The port the fixture is listening on. */ + protected abstract int port(); + + private URI endpoint() { + return URI.create("ws://localhost:" + port() + WebSocketFixtureApplication.PATH); + } + + @Test + @DisplayName("a connection opens and echoes") + void connectionOpensAndEchoes() throws Exception { + try (Peer peer = Peer.connect(endpoint())) { + peer.send("hello"); + + assertThat(peer.awaitMessage()).isEqualTo("echo:hello"); + } + } + + @Test + @DisplayName("a message inside the bound round-trips whole") + void messageWithinBoundRoundTrips() throws Exception { + String payload = "x".repeat(WebSocketFixtureApplication.MAX_FRAME_BYTES - 10); + + try (Peer peer = Peer.connect(endpoint())) { + peer.send(payload); + + assertThat(peer.awaitMessage()).isEqualTo("echo:" + payload); + } + } + + @Test + @DisplayName("an oversized message closes the connection with 1009") + void oversizedMessageClosesWith1009() throws Exception { + // 1009 rather than a private code, so a browser surfaces it with its own wording and the + // developer learns what happened without the client library knowing this API. + try (Peer peer = Peer.connect(endpoint())) { + peer.send("x".repeat(WebSocketFixtureApplication.MAX_MESSAGE_BYTES * 4)); + + CloseStatus status = peer.awaitClose(); + assertThat(status.getCode()).isEqualTo(1009); + } + } + + @Test + @DisplayName("a refused message does not leave the connection half-open") + void refusedMessageClosesCleanly() throws Exception { + // The alternative is a 1006 — "closed abnormally, no reason" — which is indistinguishable from + // a network failure and sends a client into its most aggressive reconnect path. + try (Peer peer = Peer.connect(endpoint())) { + peer.send("x".repeat(WebSocketFixtureApplication.MAX_MESSAGE_BYTES * 4)); + + CloseStatus status = peer.awaitClose(); + assertThat(status.getCode()).isNotEqualTo(1006); + } + } + + @Test + @DisplayName("a connection that sends nothing still opens and closes cleanly") + void silentConnectionClosesCleanly() throws Exception { + Peer peer = Peer.connect(endpoint()); + peer.close(); + + assertThat(peer.closed()).isTrue(); + } + + @Test + @DisplayName("many messages on one connection keep their order") + void messagesKeepTheirOrder() throws Exception { + // The serialized writer's whole purpose, observed from the peer. Interleaved frames would + // arrive as a corrupt stream rather than as reordered messages, so this also proves the writer + // never let two sends overlap. + try (Peer peer = Peer.connect(endpoint())) { + for (int index = 0; index < 50; index++) { + peer.send("m" + index); + } + + List received = peer.awaitMessages(50); + for (int index = 0; index < 50; index++) { + assertThat(received.get(index)).isEqualTo("echo:m" + index); + } + } + } + + @Test + @DisplayName("several connections are independent") + void connectionsAreIndependent() throws Exception { + try (Peer alice = Peer.connect(endpoint()); + Peer bob = Peer.connect(endpoint())) { + alice.send("alice"); + bob.send("bob"); + + assertThat(alice.awaitMessage()).isEqualTo("echo:alice"); + assertThat(bob.awaitMessage()).isEqualTo("echo:bob"); + } + } + + /** A client peer over the real transport. */ + protected static final class Peer implements AutoCloseable { + + private final WebSocketSession session; + private final List messages = new CopyOnWriteArrayList<>(); + private final AtomicReference closeStatus = new AtomicReference<>(); + private final CountDownLatch closed = new CountDownLatch(1); + private volatile CountDownLatch expected = new CountDownLatch(1); + + private Peer(WebSocketSession session) { + this.session = session; + } + + public static Peer connect(URI endpoint) throws Exception { + AtomicReference holder = new AtomicReference<>(); + TextWebSocketHandler handler = + new TextWebSocketHandler() { + @Override + protected void handleTextMessage(WebSocketSession session, TextMessage message) { + Peer peer = holder.get(); + peer.messages.add(message.getPayload()); + peer.expected.countDown(); + } + + @Override + public void afterConnectionClosed(WebSocketSession session, CloseStatus status) { + Peer peer = holder.get(); + peer.closeStatus.set(status); + peer.closed.countDown(); + } + }; + CompletableFuture future = + new StandardWebSocketClient().execute(handler, new WebSocketHttpHeaders(), endpoint); + WebSocketSession session = future.get(10, TimeUnit.SECONDS); + Peer peer = new Peer(session); + holder.set(peer); + return peer; + } + + public void send(String payload) throws Exception { + session.sendMessage(new TextMessage(payload)); + } + + public String awaitMessage() throws Exception { + if (!expected.await(10, TimeUnit.SECONDS)) { + throw new AssertionError("no message arrived within 10s"); + } + return messages.get(messages.size() - 1); + } + + public List awaitMessages(int count) throws Exception { + long deadline = System.nanoTime() + Duration.ofSeconds(20).toNanos(); + while (messages.size() < count && System.nanoTime() < deadline) { + Thread.sleep(10); + } + if (messages.size() < count) { + throw new AssertionError("only " + messages.size() + " of " + count + " messages arrived"); + } + return List.copyOf(messages); + } + + public CloseStatus awaitClose() throws Exception { + if (!closed.await(10, TimeUnit.SECONDS)) { + throw new AssertionError("the connection did not close within 10s"); + } + return closeStatus.get(); + } + + public boolean closed() throws Exception { + return closed.await(10, TimeUnit.SECONDS); + } + + @Override + public void close() throws Exception { + if (session.isOpen()) { + session.close(); + } + } + } +} diff --git a/src/adapter/outbound/cache-redis/gradle.lockfile b/src/adapter/outbound/cache-redis/gradle.lockfile index e77554e5..062d16a3 100644 --- a/src/adapter/outbound/cache-redis/gradle.lockfile +++ b/src/adapter/outbound/cache-redis/gradle.lockfile @@ -2,19 +2,18 @@ # Manual edits can break the build and are not advised. # This file is expected to be part of source control. biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=testCompileClasspath -ch.qos.logback:logback-classic:1.5.21=testCompileClasspath,testRuntimeClasspath -ch.qos.logback:logback-core:1.5.21=testCompileClasspath,testRuntimeClasspath -com.fasterxml.jackson.core:jackson-annotations:2.20=compileClasspath,testCompileClasspath,testRuntimeClasspath -com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor +ch.qos.logback:logback-classic:1.5.38=testCompileClasspath,testRuntimeClasspath +ch.qos.logback:logback-core:1.5.38=testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-annotations:2.21=compileClasspath,testCompileClasspath,testRuntimeClasspath +com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,testAnnotationProcessor com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs -com.github.spotbugs:spotbugs-annotations:4.8.6=testCompileClasspath com.github.spotbugs:spotbugs:4.10.2=spotbugs com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor -com.google.code.findbugs:jsr305:3.0.2=checkstyle,spotbugs,testCompileClasspath +com.google.code.findbugs:jsr305:3.0.2=checkstyle,spotbugs com.google.code.gson:gson:2.13.2=spotbugs com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor com.google.errorprone:error_prone_annotations:2.38.0=testCompileClasspath @@ -31,19 +30,19 @@ com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=anno com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins -com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath +com.jayway.jsonpath:json-path:2.10.0=testCompileClasspath,testRuntimeClasspath com.puppycrawl.tools:checkstyle:13.5.0=checkstyle com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath commons-beanutils:commons-beanutils:1.11.0=checkstyle commons-collections:commons-collections:3.2.2=checkstyle commons-io:commons-io:2.21.0=spotbugs -commons-logging:commons-logging:1.3.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +commons-logging:commons-logging:1.3.6=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath info.picocli:picocli:4.7.7=checkstyle io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor -io.lettuce:lettuce-core:6.8.1.RELEASE=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.lettuce:lettuce-core:6.8.2.RELEASE=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-commons:1.16.7=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-observation:1.16.7=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.netty:netty-buffer:4.2.17.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.netty:netty-codec-base:4.2.17.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.netty:netty-codec-dns:4.2.17.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath @@ -53,12 +52,12 @@ io.netty:netty-resolver-dns:4.2.17.Final=compileClasspath,runtimeClasspath,testC io.netty:netty-resolver:4.2.17.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.netty:netty-transport-native-unix-common:4.2.17.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.netty:netty-transport:4.2.17.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.projectreactor:reactor-core:3.8.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.projectreactor:reactor-core:3.8.7=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath jakarta.activation:jakarta.activation-api:2.1.4=testCompileClasspath,testRuntimeClasspath jakarta.annotation:jakarta.annotation-api:3.0.0=testCompileClasspath,testRuntimeClasspath -jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=testCompileClasspath,testRuntimeClasspath +jakarta.xml.bind:jakarta.xml.bind-api:4.0.5=testCompileClasspath,testRuntimeClasspath javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor -jaxen:jaxen:2.0.0=spotbugs +jaxen:jaxen:2.0.6=spotbugs net.bytebuddy:byte-buddy-agent:1.17.8=testCompileClasspath,testRuntimeClasspath net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath net.minidev:accessors-smart:2.6.0=testCompileClasspath,testRuntimeClasspath @@ -71,19 +70,19 @@ org.apache.commons:commons-text:1.15.0=spotbugs org.apache.commons:commons-text:1.3=checkstyle org.apache.httpcomponents:httpclient:4.5.13=checkstyle org.apache.httpcomponents:httpcore:4.4.16=checkstyle -org.apache.logging.log4j:log4j-api:2.25.2=spotbugs,testCompileClasspath,testRuntimeClasspath -org.apache.logging.log4j:log4j-core:2.25.2=spotbugs -org.apache.logging.log4j:log4j-to-slf4j:2.25.2=testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-api:2.25.5=spotbugs,testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-core:2.25.5=spotbugs +org.apache.logging.log4j:log4j-to-slf4j:2.25.5=testCompileClasspath,testRuntimeClasspath org.apache.maven.doxia:doxia-core:1.12.0=checkstyle org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle -org.apache.tomcat.embed:tomcat-embed-core:11.0.14=testCompileClasspath,testRuntimeClasspath -org.apache.tomcat.embed:tomcat-embed-el:11.0.14=testCompileClasspath,testRuntimeClasspath -org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-core:11.0.24=testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-el:11.0.24=testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-websocket:11.0.24=testCompileClasspath,testRuntimeClasspath org.apache.xbean:xbean-reflect:3.7=checkstyle org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath -org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath +org.assertj:assertj-core:3.27.7=testCompileClasspath,testRuntimeClasspath org.awaitility:awaitility:4.3.0=testCompileClasspath,testRuntimeClasspath org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle @@ -92,15 +91,15 @@ org.codehaus.plexus:plexus-utils:3.3.0=checkstyle org.dom4j:dom4j:2.2.0=spotbugs org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath org.javassist:javassist:3.28.0-GA=checkstyle -org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath -org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-engine:6.0.1=testRuntimeClasspath -org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath -org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath +org.jspecify:jspecify:1.0.1=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.3=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.3=testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.3=testRuntimeClasspath +org.junit:junit-bom:6.0.3=testCompileClasspath,testRuntimeClasspath org.junit:junit-bom:6.1.0=spotbugs org.mockito:mockito-core:5.20.0=mockitoAgent,testCompileClasspath,testRuntimeClasspath org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath @@ -120,47 +119,46 @@ org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor org.reactivestreams:reactive-streams:1.0.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.reflections:reflections:0.10.2=checkstyle org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath -org.slf4j:jul-to-slf4j:2.0.17=testCompileClasspath,testRuntimeClasspath -org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath -org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j -org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-configuration-processor:4.0.0=annotationProcessor -org.springframework.boot:spring-boot-health:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-http-converter:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-restclient:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-servlet:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-logging:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-web-server:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-aop:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-beans:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-context:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-core:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-expression:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-test:7.0.1=testCompileClasspath,testRuntimeClasspath -org.springframework:spring-web:7.0.1=testCompileClasspath,testRuntimeClasspath -org.springframework:spring-webmvc:7.0.1=testCompileClasspath,testRuntimeClasspath +org.slf4j:jul-to-slf4j:2.0.18=testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-api:2.0.18=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.18=checkstyle +org.springframework.boot:spring-boot-autoconfigure:4.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-configuration-processor:4.0.8=annotationProcessor +org.springframework.boot:spring-boot-health:4.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-http-converter:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-jackson:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-resttestclient:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-servlet:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-jackson-test:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-jackson:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-logging:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-test:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-webmvc-test:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-webmvc:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-test-autoconfigure:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-test:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-tomcat:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-web-server:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-webmvc-test:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-webmvc:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot:4.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-aop:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-beans:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-context:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-core:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-expression:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-test:7.0.9=testCompileClasspath,testRuntimeClasspath +org.springframework:spring-web:7.0.9=testCompileClasspath,testRuntimeClasspath +org.springframework:spring-webmvc:7.0.9=testCompileClasspath,testRuntimeClasspath org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath org.yaml:snakeyaml:2.5=testCompileClasspath,testRuntimeClasspath redis.clients.authentication:redis-authx-core:0.1.1-beta2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -tools.jackson.core:jackson-core:3.0.2=testCompileClasspath,testRuntimeClasspath -tools.jackson.core:jackson-databind:3.0.2=testCompileClasspath,testRuntimeClasspath -tools.jackson:jackson-bom:3.0.2=testCompileClasspath,testRuntimeClasspath +tools.jackson.core:jackson-core:3.1.5=testCompileClasspath,testRuntimeClasspath +tools.jackson.core:jackson-databind:3.1.5=testCompileClasspath,testRuntimeClasspath +tools.jackson:jackson-bom:3.1.5=testCompileClasspath,testRuntimeClasspath empty= diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/realtime/RealtimeKeys.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/realtime/RealtimeKeys.java new file mode 100644 index 00000000..a0975e74 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/realtime/RealtimeKeys.java @@ -0,0 +1,81 @@ +package dev.caskeleton.adapter.outbound.cache.redis.realtime; + +import dev.caskeleton.adapter.outbound.cache.redis.keyspace.CapabilityKeyspace; +import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.key.RedisNamespace; +import dev.caskeleton.application.realtime.ActorFingerprint; +import dev.caskeleton.application.realtime.RealtimeChannel; +import java.nio.charset.StandardCharsets; +import java.util.Objects; + +/** + * The physical keys the realtime registry owns, and nothing else does. + * + *

The actor appears only as the fingerprint the caller already produced. This adapter never sees + * a user id or a tenant name, and that is deliberate rather than incidental: a Redis key reaches + * MONITOR output, the slow log, {@code KEYS} during an incident and every backup — none of which + * has the access controls the application has, and all of which outlive the request. + * + *

There are three key shapes and they are separate on purpose. The per-actor hash answers "where + * is this actor", the per-node set answers "what was this node holding", and the heartbeat sorted + * set answers "which nodes are still alive". Deriving any of them from another would mean scanning: + * finding a dead node's entries by walking every actor is O(actors) on a path that runs whenever a + * node dies, which is exactly when the cluster has least to spare. + */ +public final class RealtimeKeys { + + private final CapabilityKeyspace keyspace; + + /** + * Creates the key renderer. + * + * @param namespace the deployment namespace every capability shares + * @param keyVersion the physical key layout version + */ + public RealtimeKeys(RedisNamespace namespace, int keyVersion) { + this.keyspace = new CapabilityKeyspace(namespace, "realtime", keyVersion); + } + + /** Where one actor is connected: a hash of node id to encoded registration. */ + public byte[] actorKey(RealtimeChannel channel, ActorFingerprint actor) { + Objects.requireNonNull(channel, "channel must be non-null"); + Objects.requireNonNull(actor, "actor must be non-null"); + return keyspace.key("actor", channel.value(), actor.value()); + } + + /** + * What one node is holding: a set of {@code channel/fingerprint} members. + * + *

Maintained alongside the actor hash so that evicting a dead node is a read of one set rather + * than a scan of every actor. + */ + public byte[] nodeKey(String nodeId) { + Objects.requireNonNull(nodeId, "node id must be non-null"); + return keyspace.key("node", nodeId); + } + + /** Which nodes are alive: a sorted set scored by last heartbeat, in epoch millis. */ + public byte[] heartbeatKey() { + return keyspace.key("heartbeat"); + } + + /** The fan-out channel a publisher writes to. */ + public byte[] fanoutChannel(RealtimeChannel channel) { + Objects.requireNonNull(channel, "channel must be non-null"); + return keyspace.key("fanout", channel.value()); + } + + /** The member that identifies one actor entry inside a node's set. */ + public static byte[] nodeMember(RealtimeChannel channel, ActorFingerprint actor) { + return (channel.value() + "/" + actor.value()).getBytes(StandardCharsets.UTF_8); + } + + /** Splits a node-set member back into its channel and fingerprint. */ + public static String[] splitNodeMember(byte[] member) { + String rendered = new String(member, StandardCharsets.UTF_8); + int separator = rendered.indexOf('/'); + if (separator < 0) { + throw new IllegalArgumentException("not a node-set member"); + } + return new String[] {rendered.substring(0, separator), rendered.substring(separator + 1)}; + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/realtime/RedisConnectionRegistryAdapter.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/realtime/RedisConnectionRegistryAdapter.java new file mode 100644 index 00000000..dfac7701 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/realtime/RedisConnectionRegistryAdapter.java @@ -0,0 +1,301 @@ +package dev.caskeleton.adapter.outbound.cache.redis.realtime; + +import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.operations.ExpirationCondition; +import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.operations.PageRequest; +import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.operations.ScoreRange; +import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.operations.SortedSetAddOptions; +import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.connection.RedisConnectionKind; +import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.connection.RedisLease; +import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.connection.RedisRuntimeOwner; +import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.operations.HashScanPage; +import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.operations.MemberScanPage; +import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.operations.ScoredMemberPage; +import dev.caskeleton.application.realtime.ActorFingerprint; +import dev.caskeleton.application.realtime.ConnectionRegistration; +import dev.caskeleton.application.realtime.ConnectionRegistryPort; +import dev.caskeleton.application.realtime.RealtimeChannel; +import dev.caskeleton.application.realtime.RealtimeNodeId; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.TimeUnit; + +/** + * The cluster session registry, on Redis. + * + *

Three structures, written together and read separately, because the three questions they + * answer have different shapes. + * + *

    + *
  • A hash per actor, so "where is this actor" is one read. It is read whole, which is bounded + * here in a way a general hash read is not: its fields are cluster nodes, so its size is the + * node count and not the traffic. + *
  • A set per node, so "what was this node holding" does not require walking every actor. + * Evicting a dead node by scanning actors would be O(actors) on the path that runs exactly + * when a node has just died — when the cluster has least to spare. This one is + * unbounded in the number of actors, so it is read by cursor rather than whole. + *
  • A sorted set of nodes scored by last heartbeat, so "which nodes are gone" is a range query. + *
+ * + *

Every failure degrades to "nothing found". That is not laziness about error + * handling — it is the correct answer for this port. The caller's fallback for an unknown location + * is to broadcast to the cluster and let the holder claim the message, which works. Throwing would + * turn a Redis blip into a failed user-visible operation, and a registry that is only a routing + * hint has no business doing that. + * + *

The TTL lives on the actor hash rather than on individual fields. Hash-field TTLs need Redis + * 7.4; depending on them would make this adapter refuse to run on anything older for a property the + * node heartbeat already provides more reliably — a node that dies stops refreshing, and its + * entries go when its heartbeat expires rather than one at a time. + */ +public final class RedisConnectionRegistryAdapter implements ConnectionRegistryPort { + + /** How many node-set members one eviction step reads. */ + private static final int EVICTION_PAGE = 256; + + /** How many silent nodes one eviction pass handles. */ + private static final int EVICTION_NODE_LIMIT = 64; + + private final RedisRuntimeOwner owner; + private final RealtimeKeys keys; + private final Duration commandTimeout; + + /** + * Creates the adapter. + * + * @param owner the Redis runtime owner leases come from + * @param keys renders the private physical keys this adapter owns + * @param commandTimeout the ceiling on one registry operation + */ + public RedisConnectionRegistryAdapter( + RedisRuntimeOwner owner, RealtimeKeys keys, Duration commandTimeout) { + this.owner = Objects.requireNonNull(owner, "runtime owner must be non-null"); + this.keys = Objects.requireNonNull(keys, "keys must be non-null"); + this.commandTimeout = + Objects.requireNonNull(commandTimeout, "command timeout must be non-null"); + if (commandTimeout.isNegative() || commandTimeout.isZero()) { + throw new IllegalArgumentException( + "an unbounded registry command blocks the caller on a datastore that is only a routing" + + " hint"); + } + } + + @Override + public void announce(ConnectionRegistration registration, Duration timeToLive) { + Objects.requireNonNull(registration, "registration must be non-null"); + Objects.requireNonNull(timeToLive, "time to live must be non-null"); + byte[] actorKey = keys.actorKey(registration.channel(), registration.actor()); + byte[] nodeKey = keys.nodeKey(registration.nodeId().value()); + try (RedisLease lease = owner.borrow(RedisConnectionKind.REGULAR)) { + await( + lease + .gateway() + .hashPut( + actorKey, + utf8(registration.nodeId().value()), + RegistrationCodec.encode(registration))); + // Refreshed on every announce. A node that keeps reporting keeps its entry alive; one that + // stops loses it without anybody having to notice. + await(lease.gateway().expire(actorKey, timeToLive, ExpirationCondition.ALWAYS)); + await( + lease + .gateway() + .setAdd( + nodeKey, + List.of(RealtimeKeys.nodeMember(registration.channel(), registration.actor())))); + // The node set outlives the entries it points at, deliberately. It is what an eviction reads, + // and a set that expired first would leave those entries with nothing able to find them. + await( + lease.gateway().expire(nodeKey, timeToLive.multipliedBy(4), ExpirationCondition.ALWAYS)); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + } catch (Exception degraded) { + // The actor is simply not findable, and the caller broadcasts. + Objects.requireNonNull(degraded); + } + } + + @Override + public void withdraw(ActorFingerprint actor, RealtimeChannel channel, RealtimeNodeId nodeId) { + Objects.requireNonNull(actor, "actor must be non-null"); + Objects.requireNonNull(channel, "channel must be non-null"); + Objects.requireNonNull(nodeId, "node id must be non-null"); + try (RedisLease lease = owner.borrow(RedisConnectionKind.REGULAR)) { + await( + lease.gateway().hashDelete(keys.actorKey(channel, actor), List.of(utf8(nodeId.value())))); + await( + lease + .gateway() + .setRemove( + keys.nodeKey(nodeId.value()), List.of(RealtimeKeys.nodeMember(channel, actor)))); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + } catch (Exception degraded) { + // A withdrawal that did not happen leaves an entry the TTL and the node heartbeat both + // remove. Failing loudly here would turn an ordinary disconnect into an error. + Objects.requireNonNull(degraded); + } + } + + @Override + public List locate( + ActorFingerprint actor, RealtimeChannel channel, Instant now) { + Objects.requireNonNull(actor, "actor must be non-null"); + Objects.requireNonNull(channel, "channel must be non-null"); + Objects.requireNonNull(now, "now must be non-null"); + try (RedisLease lease = owner.borrow(RedisConnectionKind.REGULAR)) { + HashScanPage page = await(lease.gateway().hashEntries(keys.actorKey(channel, actor))); + List found = new ArrayList<>(page.size()); + for (int index = 0; index < page.size(); index++) { + RealtimeNodeId nodeId = nodeIdOrNull(page.fields().get(index)); + if (nodeId == null) { + continue; + } + ConnectionRegistration decoded = + RegistrationCodec.decode(actor, channel, nodeId, page.values().get(index)); + if (decoded != null) { + found.add(decoded); + } + } + // Newest first, so a caller that takes the first entry takes the freshest report rather than + // whichever node Redis happened to return first. + found.sort(Comparator.comparing(ConnectionRegistration::observedAt).reversed()); + return List.copyOf(found); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + return List.of(); + } catch (Exception degraded) { + Objects.requireNonNull(degraded); + return List.of(); + } + } + + @Override + public void heartbeat(RealtimeNodeId nodeId, Instant now) { + Objects.requireNonNull(nodeId, "node id must be non-null"); + Objects.requireNonNull(now, "now must be non-null"); + try (RedisLease lease = owner.borrow(RedisConnectionKind.REGULAR)) { + await( + lease + .gateway() + .sortedSetAdd( + keys.heartbeatKey(), + List.of(utf8(nodeId.value())), + List.of((double) now.toEpochMilli()), + SortedSetAddOptions.upsert())); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + } catch (Exception degraded) { + // A missed heartbeat makes this node look dead to an evictor, which removes entries the node + // re-announces on its next report. Costly, not wrong. + Objects.requireNonNull(degraded); + } + } + + @Override + public List evictSilentNodes(Duration heartbeatTimeout, Instant now) { + Objects.requireNonNull(heartbeatTimeout, "heartbeat timeout must be non-null"); + Objects.requireNonNull(now, "now must be non-null"); + if (heartbeatTimeout.isNegative() || heartbeatTimeout.isZero()) { + throw new IllegalArgumentException( + "a zero heartbeat timeout evicts every node on every pass, including the live ones"); + } + double cutoff = (double) now.minus(heartbeatTimeout).toEpochMilli(); + List evicted = new ArrayList<>(); + List handled = new ArrayList<>(); + try (RedisLease lease = owner.borrow(RedisConnectionKind.REGULAR)) { + ScoredMemberPage silent = + await( + lease + .gateway() + .sortedSetRangeByScore( + keys.heartbeatKey(), + new ScoreRange(Double.NEGATIVE_INFINITY, true, cutoff, true), + new PageRequest(0, EVICTION_NODE_LIMIT), + false)); + for (byte[] member : silent.members()) { + RealtimeNodeId nodeId = nodeIdOrNull(member); + handled.add(member); + if (nodeId == null) { + continue; + } + forgetNode(lease, nodeId); + evicted.add(nodeId); + } + if (!handled.isEmpty()) { + // Removed last. A node whose entries were not fully cleared keeps its heartbeat member, so + // the next pass tries again rather than leaving the entries behind for ever. + await(lease.gateway().sortedSetRemove(keys.heartbeatKey(), handled)); + } + return List.copyOf(evicted); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + return List.copyOf(evicted); + } catch (Exception degraded) { + Objects.requireNonNull(degraded); + return List.copyOf(evicted); + } + } + + private void forgetNode(RedisLease lease, RealtimeNodeId nodeId) throws Exception { + byte[] nodeKey = keys.nodeKey(nodeId.value()); + String cursor = "0"; + do { + MemberScanPage page = + await(lease.gateway().setScan(nodeKey, cursor, EVICTION_PAGE, Optional.empty())); + for (byte[] member : page.members()) { + removeActorEntry(lease, nodeId, member); + } + cursor = page.nextCursor(); + // By cursor rather than whole: this set is one member per actor the node was holding, so a + // single read of it on a busy node is exactly the unbounded reply the SDK forbids. + } while (!"0".equals(cursor)); + await(lease.gateway().delete(List.of(nodeKey))); + } + + private void removeActorEntry(RedisLease lease, RealtimeNodeId nodeId, byte[] member) + throws Exception { + String[] parts; + try { + parts = RealtimeKeys.splitNodeMember(member); + } catch (IllegalArgumentException malformed) { + Objects.requireNonNull(malformed); + return; + } + RealtimeChannel channel; + ActorFingerprint actor; + try { + channel = new RealtimeChannel(parts[0]); + actor = new ActorFingerprint(parts[1]); + } catch (IllegalArgumentException malformed) { + // Written by something that is not this adapter. Deleting the node set below takes it with + // it, and guessing at a key from a value we cannot parse would delete somebody else's. + Objects.requireNonNull(malformed); + return; + } + await(lease.gateway().hashDelete(keys.actorKey(channel, actor), List.of(utf8(nodeId.value())))); + } + + private RealtimeNodeId nodeIdOrNull(byte[] raw) { + try { + return new RealtimeNodeId(new String(raw, StandardCharsets.UTF_8)); + } catch (IllegalArgumentException malformed) { + Objects.requireNonNull(malformed); + return null; + } + } + + private T await(CompletionStage stage) throws Exception { + return stage.toCompletableFuture().get(commandTimeout.toNanos(), TimeUnit.NANOSECONDS); + } + + private static byte[] utf8(String value) { + return value.getBytes(StandardCharsets.UTF_8); + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/realtime/RedisEphemeralFanoutAdapter.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/realtime/RedisEphemeralFanoutAdapter.java new file mode 100644 index 00000000..ba584a26 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/realtime/RedisEphemeralFanoutAdapter.java @@ -0,0 +1,133 @@ +package dev.caskeleton.adapter.outbound.cache.redis.realtime; + +import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.operations.RedisPubSubGateway; +import dev.caskeleton.application.realtime.EphemeralFanoutPort; +import dev.caskeleton.application.realtime.FanoutSubscription; +import dev.caskeleton.application.realtime.RealtimeChannel; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.BiConsumer; + +/** + * Best-effort cluster fan-out, on Redis pub/sub. + * + *

Redis pub/sub is fire-and-forget in the strongest sense: the server delivers to whoever is + * subscribed at that instant and then forgets the message entirely. There is no queue, no + * acknowledgement and no replay, so a subscriber that is reconnecting misses everything published + * while it was away and has no way to discover that it did. + * + *

That is a correct transport for presence updates and cache invalidations, whose next message + * supersedes the one that was lost, and a wrong one for anything a user is told happened. Which one + * a call site gets is decided by which port it injects, not by a flag here. + * + *

Two failure modes this adapter handles that a thin wrapper would not. + * + *

A listener's exception must not escape. Listeners run on the driver's shared + * delivery thread, so an exception leaving one stops delivery for every other subscription on that + * connection — and the failure surfaces against whichever channel happened to be next rather than + * the one that caused it. + * + *

A publish that fails is not an error. The message was ephemeral by + * construction, so "the broker did not accept it" and "it reached nobody" are the same outcome to + * the caller, and the returned count already means the latter. + */ +public final class RedisEphemeralFanoutAdapter implements EphemeralFanoutPort { + + private final RedisPubSubGateway gateway; + private final RealtimeKeys keys; + private final Duration commandTimeout; + + /** + * Creates the adapter. + * + * @param gateway the pub/sub gateway, which owns its own connections + * @param keys renders the channel names this adapter owns + * @param commandTimeout the ceiling on one publish + */ + public RedisEphemeralFanoutAdapter( + RedisPubSubGateway gateway, RealtimeKeys keys, Duration commandTimeout) { + this.gateway = Objects.requireNonNull(gateway, "pub/sub gateway must be non-null"); + this.keys = Objects.requireNonNull(keys, "keys must be non-null"); + this.commandTimeout = + Objects.requireNonNull(commandTimeout, "command timeout must be non-null"); + if (commandTimeout.isNegative() || commandTimeout.isZero()) { + throw new IllegalArgumentException( + "an unbounded publish blocks the caller on a best-effort transport"); + } + } + + @Override + public long publish(RealtimeChannel channel, byte[] payload) { + Objects.requireNonNull(channel, "channel must be non-null"); + Objects.requireNonNull(payload, "payload must be non-null"); + try { + Long delivered = + gateway + .publish(keys.fanoutChannel(channel), payload.clone()) + .toCompletableFuture() + .get(commandTimeout.toNanos(), TimeUnit.NANOSECONDS); + return delivered == null ? 0L : delivered; + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + return 0L; + } catch (Exception degraded) { + Objects.requireNonNull(degraded); + return 0L; + } + } + + @Override + public FanoutSubscription subscribe( + RealtimeChannel channel, BiConsumer listener) { + Objects.requireNonNull(channel, "channel must be non-null"); + Objects.requireNonNull(listener, "listener must be non-null"); + String target = new String(keys.fanoutChannel(channel), StandardCharsets.UTF_8); + RedisPubSubGateway.RedisSubscriptionHandle handle = + gateway.subscribe( + List.of(target), + RedisPubSubGateway.SubscriptionKind.CHANNEL, + (delivered, payload) -> deliver(channel, listener, payload)); + return new HandleSubscription(handle); + } + + private static void deliver( + RealtimeChannel channel, BiConsumer listener, byte[] payload) { + try { + listener.accept(channel, payload); + } catch (RuntimeException failure) { + // Swallowed on purpose. See the class comment: this runs on the driver's shared delivery + // thread, and letting it out stops every other subscription on the connection. + Objects.requireNonNull(failure); + } + } + + /** A subscription that closes its handle exactly once. */ + private static final class HandleSubscription implements FanoutSubscription { + + private final RedisPubSubGateway.RedisSubscriptionHandle handle; + private final AtomicBoolean open = new AtomicBoolean(true); + + HandleSubscription(RedisPubSubGateway.RedisSubscriptionHandle handle) { + this.handle = handle; + } + + @Override + public boolean active() { + return open.get(); + } + + @Override + public void close() { + // Guarded rather than idempotent-by-hope: this is called from a stream teardown that can run + // twice when a cancel races a completion, and unsubscribing a channel a second time would + // remove a subscription a later caller had just established. + if (open.compareAndSet(true, false)) { + handle.close(); + } + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/realtime/RegistrationCodec.java b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/realtime/RegistrationCodec.java new file mode 100644 index 00000000..81e64fe8 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/main/java/dev/caskeleton/adapter/outbound/cache/redis/realtime/RegistrationCodec.java @@ -0,0 +1,76 @@ +package dev.caskeleton.adapter.outbound.cache.redis.realtime; + +import dev.caskeleton.application.realtime.ActorFingerprint; +import dev.caskeleton.application.realtime.ConnectionRegistration; +import dev.caskeleton.application.realtime.RealtimeChannel; +import dev.caskeleton.application.realtime.RealtimeNodeId; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Objects; + +/** + * How a registration is written into a Redis hash field. + * + *

A fixed three-field text encoding rather than JSON, and the reason is what happens when it + * changes. This value is written by one node and read by another, which during a rolling deploy are + * different versions of the code; a JSON reader that gained a required field would fail on every + * entry the old nodes are still writing, for the whole rollout. A leading version token makes an + * incompatible change something the reader can detect and skip rather than throw on. + * + *

Skipping is the right response for this data specifically. An entry that cannot be decoded is + * a routing hint, so discarding it costs one broadcast; throwing would fail the whole lookup and + * take the entries that decoded fine with it. + */ +final class RegistrationCodec { + + private static final String VERSION = "v1"; + + /** ASCII unit separator: it cannot occur in a count or an epoch, so no escaping is needed. */ + private static final String SEPARATOR = ""; + + private RegistrationCodec() {} + + /** Encodes a registration for storage. */ + static byte[] encode(ConnectionRegistration registration) { + Objects.requireNonNull(registration, "registration must be non-null"); + String rendered = + VERSION + + SEPARATOR + + registration.connectionCount() + + SEPARATOR + + registration.observedAt().toEpochMilli(); + return rendered.getBytes(StandardCharsets.UTF_8); + } + + /** + * Decodes a stored registration. + * + * @param actor whose entry this is, from the key rather than the value + * @param channel which feed, likewise from the key + * @param nodeId which node, from the hash field name + * @param stored the encoded value + * @return the registration, or {@code null} when the value is from an incompatible writer + */ + static ConnectionRegistration decode( + ActorFingerprint actor, RealtimeChannel channel, RealtimeNodeId nodeId, byte[] stored) { + if (stored == null) { + return null; + } + String[] parts = new String(stored, StandardCharsets.UTF_8).split(SEPARATOR, -1); + if (parts.length != 3 || !VERSION.equals(parts[0])) { + return null; + } + try { + return new ConnectionRegistration( + actor, + nodeId, + channel, + Integer.parseInt(parts[1]), + Instant.ofEpochMilli(Long.parseLong(parts[2]))); + } catch (IllegalArgumentException malformed) { + // Same reasoning as an unknown version: a routing hint that cannot be read is dropped, not + // raised. Raising would lose the entries beside it that were perfectly readable. + return null; + } + } +} diff --git a/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/realtime/RedisConnectionRegistryAdapterTest.java b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/realtime/RedisConnectionRegistryAdapterTest.java new file mode 100644 index 00000000..41e39a59 --- /dev/null +++ b/src/adapter/outbound/cache-redis/src/test/java/dev/caskeleton/adapter/outbound/cache/redis/realtime/RedisConnectionRegistryAdapterTest.java @@ -0,0 +1,262 @@ +package dev.caskeleton.adapter.outbound.cache.redis.realtime; + +import static org.assertj.core.api.Assertions.assertThat; + +import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.RedisDeploymentMode; +import dev.caskeleton.adapter.outbound.cache.redis.sdk.api.key.RedisNamespace; +import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.connection.RedisConnectionKind; +import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.connection.RedisRuntimeClient; +import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.connection.RedisRuntimeOwner; +import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.operations.InMemoryGatewayAccess; +import dev.caskeleton.adapter.outbound.cache.redis.sdk.lettuce.operations.RedisCommandGateway; +import dev.caskeleton.application.realtime.ActorFingerprint; +import dev.caskeleton.application.realtime.ConnectionRegistration; +import dev.caskeleton.application.realtime.RealtimeChannel; +import dev.caskeleton.application.realtime.RealtimeNodeId; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * The cluster registry's behaviour, including the parts that only matter when something is wrong. + * + *

Two properties get the most attention here because they are the ones a naive implementation + * gets wrong in a way nothing notices: a dead node's entries must go without scanning every actor, + * and every failure must degrade to "nothing found" rather than throwing — the caller's fallback is + * a cluster broadcast, and an exception would turn a Redis blip into a failed delivery. + */ +class RedisConnectionRegistryAdapterTest { + + private static final Instant NOW = Instant.parse("2026-08-25T10:00:00Z"); + private static final Duration TTL = Duration.ofSeconds(30); + private static final RealtimeChannel FEED = new RealtimeChannel("live-updates"); + private static final ActorFingerprint ALICE = new ActorFingerprint("fp-alice-0000001"); + private static final ActorFingerprint BOB = new ActorFingerprint("fp-bob-000000001"); + private static final RealtimeNodeId EDGE_ONE = new RealtimeNodeId("edge-1"); + private static final RealtimeNodeId EDGE_TWO = new RealtimeNodeId("edge-2"); + + private final InMemoryGatewayAccess gateway = InMemoryGatewayAccess.create(); + private final RedisConnectionRegistryAdapter adapter = adapter(gateway.gateway()); + + private static RedisConnectionRegistryAdapter adapter(RedisCommandGateway gateway) { + return adapter(new StubClient(gateway)); + } + + private static RedisConnectionRegistryAdapter adapter(RedisRuntimeClient client) { + Map limits = new EnumMap<>(RedisConnectionKind.class); + for (RedisConnectionKind kind : RedisConnectionKind.values()) { + limits.put(kind, 4); + } + return new RedisConnectionRegistryAdapter( + new RedisRuntimeOwner(client, limits, Duration.ofSeconds(1)), + new RealtimeKeys(new RedisNamespace("prod", "ca-skeleton", "shared"), 1), + Duration.ofSeconds(2)); + } + + private static ConnectionRegistration registration( + ActorFingerprint actor, RealtimeNodeId node, int connections, Instant observedAt) { + return new ConnectionRegistration(actor, node, FEED, connections, observedAt); + } + + @Test + @DisplayName("an announced actor is found on the node that announced it") + void announcedActorIsFound() { + adapter.announce(registration(ALICE, EDGE_ONE, 2, NOW), TTL); + + List found = adapter.locate(ALICE, FEED, NOW); + + assertThat(found).hasSize(1); + assertThat(found.get(0).nodeId()).isEqualTo(EDGE_ONE); + assertThat(found.get(0).connectionCount()).isEqualTo(2); + assertThat(found.get(0).observedAt()).isEqualTo(NOW); + } + + @Test + @DisplayName("an actor on two nodes is reported from both, newest first") + void multipleNodesAreReportedNewestFirst() { + // Newest first so a caller taking the first entry takes the freshest report rather than + // whichever node Redis happened to return first. + adapter.announce(registration(ALICE, EDGE_ONE, 1, NOW), TTL); + adapter.announce(registration(ALICE, EDGE_TWO, 3, NOW.plusSeconds(5)), TTL); + + assertThat(adapter.locate(ALICE, FEED, NOW.plusSeconds(5))) + .extracting(ConnectionRegistration::nodeId) + .containsExactly(EDGE_TWO, EDGE_ONE); + } + + @Test + @DisplayName("a re-announce from the same node replaces rather than duplicates") + void reAnnounceReplaces() { + adapter.announce(registration(ALICE, EDGE_ONE, 1, NOW), TTL); + adapter.announce(registration(ALICE, EDGE_ONE, 4, NOW.plusSeconds(1)), TTL); + + assertThat(adapter.locate(ALICE, FEED, NOW.plusSeconds(1))) + .singleElement() + .extracting(ConnectionRegistration::connectionCount) + .isEqualTo(4); + } + + @Test + @DisplayName("a withdrawal removes only the withdrawing node's entry") + void withdrawalIsScopedToItsNode() { + adapter.announce(registration(ALICE, EDGE_ONE, 1, NOW), TTL); + adapter.announce(registration(ALICE, EDGE_TWO, 1, NOW), TTL); + + adapter.withdraw(ALICE, FEED, EDGE_ONE); + + assertThat(adapter.locate(ALICE, FEED, NOW)) + .extracting(ConnectionRegistration::nodeId) + .containsExactly(EDGE_TWO); + } + + @Test + @DisplayName("evicting a silent node removes every actor it was holding") + void evictingANodeRemovesItsActors() { + // Via the node's own set, not by scanning actors. Scanning would be O(actors) on the path that + // runs exactly when a node has just died. + adapter.announce(registration(ALICE, EDGE_ONE, 1, NOW), TTL); + adapter.announce(registration(BOB, EDGE_ONE, 1, NOW), TTL); + adapter.announce(registration(ALICE, EDGE_TWO, 1, NOW), TTL); + adapter.heartbeat(EDGE_ONE, NOW); + adapter.heartbeat(EDGE_TWO, NOW.plusSeconds(60)); + + List evicted = + adapter.evictSilentNodes(Duration.ofSeconds(30), NOW.plusSeconds(60)); + + assertThat(evicted).containsExactly(EDGE_ONE); + assertThat(adapter.locate(BOB, FEED, NOW.plusSeconds(60))).isEmpty(); + assertThat(adapter.locate(ALICE, FEED, NOW.plusSeconds(60))) + .extracting(ConnectionRegistration::nodeId) + .containsExactly(EDGE_TWO); + } + + @Test + @DisplayName("a node that is still reporting is not evicted") + void reportingNodeSurvives() { + adapter.announce(registration(ALICE, EDGE_ONE, 1, NOW), TTL); + adapter.heartbeat(EDGE_ONE, NOW.plusSeconds(50)); + + assertThat(adapter.evictSilentNodes(Duration.ofSeconds(30), NOW.plusSeconds(60))).isEmpty(); + assertThat(adapter.locate(ALICE, FEED, NOW.plusSeconds(60))).hasSize(1); + } + + @Test + @DisplayName("an unknown actor is empty rather than an error") + void unknownActorIsEmpty() { + assertThat(adapter.locate(ALICE, FEED, NOW)).isEmpty(); + } + + @Test + @DisplayName("a registry that cannot be reached reports nothing found") + void unreachableRegistryDegrades() { + // The property that decides whether a Redis blip is a routing miss or a failed delivery. The + // caller broadcasts on empty, which is correct; an exception here would propagate. + RedisConnectionRegistryAdapter broken = adapter((RedisRuntimeClient) new FailingGateway()); + + assertThat(broken.locate(ALICE, FEED, NOW)).isEmpty(); + assertThat(broken.evictSilentNodes(Duration.ofSeconds(30), NOW)).isEmpty(); + } + + @Test + @DisplayName("an announce against an unreachable registry does not throw") + void unreachableAnnounceDoesNotThrow() { + RedisConnectionRegistryAdapter broken = adapter((RedisRuntimeClient) new FailingGateway()); + + broken.announce(registration(ALICE, EDGE_ONE, 1, NOW), TTL); + broken.withdraw(ALICE, FEED, EDGE_ONE); + broken.heartbeat(EDGE_ONE, NOW); + } + + @Test + @DisplayName("a key carries the fingerprint and never a subject") + void keysCarryNoIdentity() { + // Redis keys reach MONITOR, the slow log and every backup — none of which has the access + // controls the application has. + RealtimeKeys keys = new RealtimeKeys(new RedisNamespace("prod", "ca-skeleton", "shared"), 1); + String rendered = new String(keys.actorKey(FEED, ALICE), StandardCharsets.UTF_8); + + assertThat(rendered).contains(ALICE.value()).contains("realtime").contains("live-updates"); + } + + @Test + @DisplayName("a stored entry from an incompatible writer is skipped, not fatal") + void incompatibleEntriesAreSkipped() { + // During a rolling deploy both versions write. An entry that cannot be decoded is a routing + // hint, so dropping it costs one broadcast; throwing would lose the entries beside it that + // decoded perfectly well. + assertThat( + RegistrationCodec.decode( + ALICE, FEED, EDGE_ONE, "v912".getBytes(StandardCharsets.UTF_8))) + .isNull(); + assertThat( + RegistrationCodec.decode( + ALICE, FEED, EDGE_ONE, "garbage".getBytes(StandardCharsets.UTF_8))) + .isNull(); + assertThat(RegistrationCodec.decode(ALICE, FEED, EDGE_ONE, null)).isNull(); + } + + @Test + @DisplayName("a zero heartbeat timeout is refused rather than evicting everything") + void zeroHeartbeatTimeoutIsRefused() { + assertThat( + org.assertj.core.api.Assertions.catchThrowable( + () -> adapter.evictSilentNodes(Duration.ZERO, NOW))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("including the live ones"); + } + + /** A gateway whose every command fails, standing in for an unreachable server. */ + private record FailingGateway() implements RedisRuntimeClient { + + @Override + public RedisDeploymentMode mode() { + return RedisDeploymentMode.STANDALONE; + } + + @Override + public RedisRuntimeClient.RedisLaneConnection openLane( + RedisConnectionKind kind, Optional routingKey) { + throw new IllegalStateException("redis is unreachable"); + } + + @Override + public void close() {} + } + + /** The in-memory gateway behind a runtime client. */ + private record StubClient(RedisCommandGateway gateway) implements RedisRuntimeClient { + + @Override + public RedisDeploymentMode mode() { + return RedisDeploymentMode.STANDALONE; + } + + @Override + public RedisRuntimeClient.RedisLaneConnection openLane( + RedisConnectionKind kind, Optional routingKey) { + return new RedisRuntimeClient.RedisLaneConnection() { + @Override + public RedisCommandGateway gateway() { + return gateway; + } + + @Override + public boolean open() { + return true; + } + + @Override + public void close() {} + }; + } + + @Override + public void close() {} + } +} diff --git a/src/adapter/outbound/fileserver/gradle.lockfile b/src/adapter/outbound/fileserver/gradle.lockfile index 48f856a2..17910be7 100644 --- a/src/adapter/outbound/fileserver/gradle.lockfile +++ b/src/adapter/outbound/fileserver/gradle.lockfile @@ -2,19 +2,18 @@ # Manual edits can break the build and are not advised. # This file is expected to be part of source control. biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=testCompileClasspath -ch.qos.logback:logback-classic:1.5.21=testCompileClasspath,testRuntimeClasspath -ch.qos.logback:logback-core:1.5.21=testCompileClasspath,testRuntimeClasspath -com.fasterxml.jackson.core:jackson-annotations:2.20=testCompileClasspath,testRuntimeClasspath -com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor +ch.qos.logback:logback-classic:1.5.38=testCompileClasspath,testRuntimeClasspath +ch.qos.logback:logback-core:1.5.38=testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-annotations:2.21=testCompileClasspath,testRuntimeClasspath +com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,testAnnotationProcessor com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs -com.github.spotbugs:spotbugs-annotations:4.8.6=testCompileClasspath com.github.spotbugs:spotbugs:4.10.2=spotbugs com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor -com.google.code.findbugs:jsr305:3.0.2=checkstyle,spotbugs,testCompileClasspath +com.google.code.findbugs:jsr305:3.0.2=checkstyle,spotbugs com.google.code.gson:gson:2.13.2=spotbugs com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor com.google.errorprone:error_prone_annotations:2.38.0=testCompileClasspath @@ -31,23 +30,23 @@ com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=anno com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins -com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath +com.jayway.jsonpath:json-path:2.10.0=testCompileClasspath,testRuntimeClasspath com.puppycrawl.tools:checkstyle:13.5.0=checkstyle com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath commons-beanutils:commons-beanutils:1.11.0=checkstyle commons-collections:commons-collections:3.2.2=checkstyle commons-io:commons-io:2.21.0=spotbugs -commons-logging:commons-logging:1.3.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +commons-logging:commons-logging:1.3.6=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath info.picocli:picocli:4.7.7=checkstyle io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor -io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-commons:1.16.7=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-observation:1.16.7=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath jakarta.activation:jakarta.activation-api:2.1.4=testCompileClasspath,testRuntimeClasspath jakarta.annotation:jakarta.annotation-api:3.0.0=testCompileClasspath,testRuntimeClasspath -jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=testCompileClasspath,testRuntimeClasspath +jakarta.xml.bind:jakarta.xml.bind-api:4.0.5=testCompileClasspath,testRuntimeClasspath javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor -jaxen:jaxen:2.0.0=spotbugs +jaxen:jaxen:2.0.6=spotbugs net.bytebuddy:byte-buddy-agent:1.17.8=testCompileClasspath,testRuntimeClasspath net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath net.minidev:accessors-smart:2.6.0=testCompileClasspath,testRuntimeClasspath @@ -60,19 +59,19 @@ org.apache.commons:commons-text:1.15.0=spotbugs org.apache.commons:commons-text:1.3=checkstyle org.apache.httpcomponents:httpclient:4.5.13=checkstyle org.apache.httpcomponents:httpcore:4.4.16=checkstyle -org.apache.logging.log4j:log4j-api:2.25.2=spotbugs,testCompileClasspath,testRuntimeClasspath -org.apache.logging.log4j:log4j-core:2.25.2=spotbugs -org.apache.logging.log4j:log4j-to-slf4j:2.25.2=testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-api:2.25.5=spotbugs,testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-core:2.25.5=spotbugs +org.apache.logging.log4j:log4j-to-slf4j:2.25.5=testCompileClasspath,testRuntimeClasspath org.apache.maven.doxia:doxia-core:1.12.0=checkstyle org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle -org.apache.tomcat.embed:tomcat-embed-core:11.0.14=testCompileClasspath,testRuntimeClasspath -org.apache.tomcat.embed:tomcat-embed-el:11.0.14=testCompileClasspath,testRuntimeClasspath -org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-core:11.0.24=testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-el:11.0.24=testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-websocket:11.0.24=testCompileClasspath,testRuntimeClasspath org.apache.xbean:xbean-reflect:3.7=checkstyle org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath -org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath +org.assertj:assertj-core:3.27.7=testCompileClasspath,testRuntimeClasspath org.awaitility:awaitility:4.3.0=testCompileClasspath,testRuntimeClasspath org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle @@ -81,15 +80,15 @@ org.codehaus.plexus:plexus-utils:3.3.0=checkstyle org.dom4j:dom4j:2.2.0=spotbugs org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath org.javassist:javassist:3.28.0-GA=checkstyle -org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath -org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-engine:6.0.1=testRuntimeClasspath -org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath -org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath +org.jspecify:jspecify:1.0.1=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.3=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.3=testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.3=testRuntimeClasspath +org.junit:junit-bom:6.0.3=testCompileClasspath,testRuntimeClasspath org.junit:junit-bom:6.1.0=spotbugs org.mockito:mockito-core:5.20.0=mockitoAgent,testCompileClasspath,testRuntimeClasspath org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath @@ -108,45 +107,44 @@ org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor org.reflections:reflections:0.10.2=checkstyle org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath -org.slf4j:jul-to-slf4j:2.0.17=testCompileClasspath,testRuntimeClasspath -org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath -org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j -org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-configuration-processor:4.0.0=annotationProcessor -org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-http-converter:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-restclient:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-servlet:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-logging:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-web-server:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-aop:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-beans:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-context:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-core:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-expression:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-test:7.0.1=testCompileClasspath,testRuntimeClasspath -org.springframework:spring-web:7.0.1=testCompileClasspath,testRuntimeClasspath -org.springframework:spring-webmvc:7.0.1=testCompileClasspath,testRuntimeClasspath +org.slf4j:jul-to-slf4j:2.0.18=testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-api:2.0.18=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.18=checkstyle +org.springframework.boot:spring-boot-autoconfigure:4.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-configuration-processor:4.0.8=annotationProcessor +org.springframework.boot:spring-boot-http-converter:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-jackson:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-resttestclient:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-servlet:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-jackson-test:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-jackson:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-logging:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-test:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-webmvc-test:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-webmvc:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-test-autoconfigure:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-test:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-tomcat:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-web-server:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-webmvc-test:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-webmvc:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot:4.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-aop:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-beans:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-context:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-core:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-expression:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-test:7.0.9=testCompileClasspath,testRuntimeClasspath +org.springframework:spring-web:7.0.9=testCompileClasspath,testRuntimeClasspath +org.springframework:spring-webmvc:7.0.9=testCompileClasspath,testRuntimeClasspath org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath org.yaml:snakeyaml:2.5=testCompileClasspath,testRuntimeClasspath -tools.jackson.core:jackson-core:3.0.2=testCompileClasspath,testRuntimeClasspath -tools.jackson.core:jackson-databind:3.0.2=testCompileClasspath,testRuntimeClasspath -tools.jackson:jackson-bom:3.0.2=testCompileClasspath,testRuntimeClasspath +tools.jackson.core:jackson-core:3.1.5=testCompileClasspath,testRuntimeClasspath +tools.jackson.core:jackson-databind:3.1.5=testCompileClasspath,testRuntimeClasspath +tools.jackson:jackson-bom:3.1.5=testCompileClasspath,testRuntimeClasspath empty= diff --git a/src/adapter/outbound/httpclient/build.gradle b/src/adapter/outbound/httpclient/build.gradle index 8300f452..47e011d4 100644 --- a/src/adapter/outbound/httpclient/build.gradle +++ b/src/adapter/outbound/httpclient/build.gradle @@ -45,6 +45,15 @@ dependencies { implementation 'org.springframework.security:spring-security-oauth2-client' implementation 'com.fasterxml.jackson.core:jackson-databind' + // Jackson 3, and not optional. `RestClientResponseReader.defaultConverters()` constructs a + // `JacksonJsonHttpMessageConverter`, which is Spring 7's converter and is built on + // `tools.jackson.databind.json.JsonMapper`. javac never noticed: the constructor's signature + // does not mention the type, so compiling the call needs nothing. Loading the class does, and + // Jackson 3 reached only this leaf's test and jmh classpaths — so the reader worked in every + // test and would have thrown NoClassDefFoundError in any deployment that did not happen to + // have Jackson 3 from somewhere else. The Eclipse compiler the editor runs reported it as an + // error, which is what it is. Jackson 2 stays: two files still read `JsonNode` with it. + implementation 'tools.jackson.core:jackson-databind' implementation 'io.micrometer:micrometer-core' implementation 'org.slf4j:slf4j-api' diff --git a/src/adapter/outbound/httpclient/gradle.lockfile b/src/adapter/outbound/httpclient/gradle.lockfile index d8e5979c..5f186c01 100644 --- a/src/adapter/outbound/httpclient/gradle.lockfile +++ b/src/adapter/outbound/httpclient/gradle.lockfile @@ -2,25 +2,24 @@ # Manual edits can break the build and are not advised. # This file is expected to be part of source control. biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=httpClientPerformanceTestCompileClasspath,jmhCompileClasspath,testCompileClasspath,testkitCompileClasspath -ch.qos.logback:logback-classic:1.5.21=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -ch.qos.logback:logback-core:1.5.21=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -com.fasterxml.jackson.core:jackson-annotations:2.20=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -com.fasterxml.jackson.core:jackson-core:2.20.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -com.fasterxml.jackson.core:jackson-databind:2.20.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -com.fasterxml.jackson:jackson-bom:2.20.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor -com.github.docker-java:docker-java-api:3.7.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -com.github.docker-java:docker-java-transport-zerodep:3.7.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -com.github.docker-java:docker-java-transport:3.7.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +ch.qos.logback:logback-classic:1.5.38=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +ch.qos.logback:logback-core:1.5.38=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +com.fasterxml.jackson.core:jackson-annotations:2.21=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +com.fasterxml.jackson.core:jackson-core:2.21.5=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +com.fasterxml.jackson.core:jackson-databind:2.21.5=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +com.fasterxml.jackson:jackson-bom:2.21.5=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor +com.github.docker-java:docker-java-api:3.7.1=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +com.github.docker-java:docker-java-transport-zerodep:3.7.1=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +com.github.docker-java:docker-java-transport:3.7.1=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs -com.github.spotbugs:spotbugs-annotations:4.8.6=httpClientPerformanceTestCompileClasspath,jmhCompileClasspath,testCompileClasspath,testkitCompileClasspath com.github.spotbugs:spotbugs:4.10.2=spotbugs com.github.stephenc.jcip:jcip-annotations:1.0-1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor com.google.auto:auto-common:1.2.2=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor -com.google.code.findbugs:jsr305:3.0.2=checkstyle,httpClientPerformanceTestCompileClasspath,jmhCompileClasspath,spotbugs,testCompileClasspath,testkitCompileClasspath +com.google.code.findbugs:jsr305:3.0.2=checkstyle,spotbugs com.google.code.gson:gson:2.13.2=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor com.google.errorprone:error_prone_annotations:2.41.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath @@ -36,7 +35,7 @@ com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=anno com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins -com.jayway.jsonpath:json-path:2.9.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +com.jayway.jsonpath:json-path:2.10.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath com.nimbusds:content-type:2.3=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath com.nimbusds:lang-tag:1.7=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath com.nimbusds:nimbus-jose-jwt:10.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath @@ -58,7 +57,7 @@ commons-codec:commons-codec:1.19.0=httpClientPerformanceTestCompileClasspath,htt commons-collections:commons-collections:3.2.2=checkstyle commons-io:commons-io:2.20.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath commons-io:commons-io:2.21.0=spotbugs -commons-logging:commons-logging:1.3.5=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +commons-logging:commons-logging:1.3.6=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath eu.rekawek.toxiproxy:toxiproxy-java:2.1.11=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath info.picocli:picocli:4.7.7=checkstyle io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor @@ -70,9 +69,9 @@ io.github.resilience4j:resilience4j-micrometer:2.2.0=compileClasspath,httpClient io.github.resilience4j:resilience4j-ratelimiter:2.2.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath io.github.resilience4j:resilience4j-retry:2.2.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath io.github.resilience4j:resilience4j-timelimiter:2.2.0=httpClientPerformanceTestRuntimeClasspath,jmhRuntimeClasspath,runtimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath -io.micrometer:micrometer-commons:1.16.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -io.micrometer:micrometer-core:1.16.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -io.micrometer:micrometer-observation:1.16.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +io.micrometer:micrometer-commons:1.16.7=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +io.micrometer:micrometer-core:1.16.7=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +io.micrometer:micrometer-observation:1.16.7=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath io.netty:netty-buffer:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath io.netty:netty-codec-base:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath io.netty:netty-codec-classes-quic:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath @@ -94,16 +93,16 @@ io.netty:netty-transport-classes-epoll:4.2.17.Final=compileClasspath,httpClientP io.netty:netty-transport-native-epoll:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath io.netty:netty-transport-native-unix-common:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath io.netty:netty-transport:4.2.17.Final=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -io.projectreactor.netty:reactor-netty-core:1.3.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -io.projectreactor.netty:reactor-netty-http:1.3.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +io.projectreactor.netty:reactor-netty-core:1.3.7=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +io.projectreactor.netty:reactor-netty-http:1.3.7=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath io.projectreactor.tools:blockhound:1.0.17.RELEASE=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -io.projectreactor:reactor-core:3.8.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -io.projectreactor:reactor-test:3.8.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +io.projectreactor:reactor-core:3.8.7=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +io.projectreactor:reactor-test:3.8.7=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath jakarta.activation:jakarta.activation-api:2.1.4=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath jakarta.annotation:jakarta.annotation-api:3.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +jakarta.xml.bind:jakarta.xml.bind-api:4.0.5=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath javax.inject:javax.inject:1=annotationProcessor,httpClientPerformanceTestAnnotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor -jaxen:jaxen:2.0.0=spotbugs +jaxen:jaxen:2.0.6=spotbugs junit:junit:4.13.2=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath net.bytebuddy:byte-buddy-agent:1.17.8=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath net.bytebuddy:byte-buddy:1.17.8=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath @@ -119,45 +118,45 @@ org.apache.commons:commons-lang3:3.20.0=checkstyle,httpClientPerformanceTestComp org.apache.commons:commons-math3:3.6.1=jmhAnnotationProcessor,jmhCompileClasspath,jmhRuntimeClasspath org.apache.commons:commons-text:1.15.0=spotbugs org.apache.commons:commons-text:1.3=checkstyle -org.apache.httpcomponents.client5:httpclient5:5.5.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.apache.httpcomponents.client5:httpclient5:5.5.2=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath org.apache.httpcomponents.core5:httpcore5-h2:5.3.6=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath org.apache.httpcomponents.core5:httpcore5:5.3.6=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath org.apache.httpcomponents:httpclient:4.5.13=checkstyle org.apache.httpcomponents:httpcore:4.4.16=checkstyle -org.apache.logging.log4j:log4j-api:2.25.2=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.apache.logging.log4j:log4j-core:2.25.2=spotbugs -org.apache.logging.log4j:log4j-to-slf4j:2.25.2=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.apache.logging.log4j:log4j-api:2.25.5=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.apache.logging.log4j:log4j-core:2.25.5=spotbugs +org.apache.logging.log4j:log4j-to-slf4j:2.25.5=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath org.apache.maven.doxia:doxia-core:1.12.0=checkstyle org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle -org.apache.tomcat.embed:tomcat-embed-core:11.0.14=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.apache.tomcat.embed:tomcat-embed-el:11.0.14=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-core:11.0.24=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-el:11.0.24=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-websocket:11.0.24=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath org.apache.xbean:xbean-reflect:3.7=checkstyle -org.apiguardian:apiguardian-api:1.1.2=compileClasspath,httpClientPerformanceTestCompileClasspath,jmhCompileClasspath,testCompileClasspath,testkitCompileClasspath -org.assertj:assertj-core:3.27.6=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.apiguardian:apiguardian-api:1.1.2=httpClientPerformanceTestCompileClasspath,jmhCompileClasspath,testCompileClasspath,testkitCompileClasspath +org.assertj:assertj-core:3.27.7=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath org.awaitility:awaitility:4.3.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle org.codehaus.plexus:plexus-utils:3.3.0=checkstyle org.dom4j:dom4j:2.2.0=spotbugs -org.eclipse.jetty.compression:jetty-compression-common:12.1.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.eclipse.jetty.compression:jetty-compression-gzip:12.1.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.eclipse.jetty.http3:jetty-http3-client-transport:12.1.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.eclipse.jetty.http3:jetty-http3-client:12.1.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.eclipse.jetty.http3:jetty-http3-common:12.1.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.eclipse.jetty.http3:jetty-http3-qpack:12.1.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.eclipse.jetty.quic:jetty-quic-api:12.1.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.eclipse.jetty.quic:jetty-quic-client:12.1.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.eclipse.jetty.quic:jetty-quic-common:12.1.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.eclipse.jetty.quic:jetty-quic-util:12.1.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.eclipse.jetty:jetty-alpn-client:12.1.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.eclipse.jetty:jetty-client:12.1.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.eclipse.jetty:jetty-http:12.1.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.eclipse.jetty:jetty-io:12.1.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.eclipse.jetty:jetty-util:12.1.4=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.eclipse.jetty.compression:jetty-compression-common:12.1.12=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.eclipse.jetty.compression:jetty-compression-gzip:12.1.12=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.eclipse.jetty.http3:jetty-http3-client-transport:12.1.12=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.eclipse.jetty.http3:jetty-http3-client:12.1.12=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.eclipse.jetty.http3:jetty-http3-common:12.1.12=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.eclipse.jetty.http3:jetty-http3-qpack:12.1.12=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.eclipse.jetty.quic:jetty-quic-api:12.1.12=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.eclipse.jetty.quic:jetty-quic-client:12.1.12=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.eclipse.jetty.quic:jetty-quic-common:12.1.12=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.eclipse.jetty.quic:jetty-quic-util:12.1.12=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.eclipse.jetty:jetty-alpn-client:12.1.12=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.eclipse.jetty:jetty-client:12.1.12=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.eclipse.jetty:jetty-http:12.1.12=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.eclipse.jetty:jetty-io:12.1.12=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.eclipse.jetty:jetty-util:12.1.12=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath org.hamcrest:hamcrest-core:3.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath org.hamcrest:hamcrest:3.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath org.hdrhistogram:HdrHistogram:2.2.2=httpClientPerformanceTestRuntimeClasspath,jmhRuntimeClasspath,runtimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath @@ -167,15 +166,15 @@ org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.2.21=httpClientPerformanceTestCompileC org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.2.21=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath org.jetbrains.kotlin:kotlin-stdlib:2.2.21=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath org.jetbrains:annotations:17.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,httpClientPerformanceTestAnnotationProcessor,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhAnnotationProcessor,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath,testkitAnnotationProcessor,testkitCompileClasspath,testkitRuntimeClasspath -org.junit.jupiter:junit-jupiter-api:6.0.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.junit.jupiter:junit-jupiter-engine:6.0.1=httpClientPerformanceTestRuntimeClasspath,jmhRuntimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath -org.junit.jupiter:junit-jupiter-params:6.0.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.junit.jupiter:junit-jupiter:6.0.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.junit.platform:junit-platform-commons:6.0.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.junit.platform:junit-platform-engine:6.0.1=httpClientPerformanceTestRuntimeClasspath,jmhRuntimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath -org.junit.platform:junit-platform-launcher:6.0.1=httpClientPerformanceTestRuntimeClasspath,jmhRuntimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath -org.junit:junit-bom:6.0.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.jspecify:jspecify:1.0.1=annotationProcessor,checkstyle,compileClasspath,httpClientPerformanceTestAnnotationProcessor,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhAnnotationProcessor,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath,testkitAnnotationProcessor,testkitCompileClasspath,testkitRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:6.0.3=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.3=httpClientPerformanceTestRuntimeClasspath,jmhRuntimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.3=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.3=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.3=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.3=httpClientPerformanceTestRuntimeClasspath,jmhRuntimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.3=httpClientPerformanceTestRuntimeClasspath,jmhRuntimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath +org.junit:junit-bom:6.0.3=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath org.junit:junit-bom:6.1.0=spotbugs org.latencyutils:LatencyUtils:2.0.3=httpClientPerformanceTestRuntimeClasspath,jmhRuntimeClasspath,runtimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath org.mockito:mockito-core:5.20.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,mockitoAgent,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath @@ -183,7 +182,7 @@ org.mockito:mockito-junit-jupiter:5.20.0=httpClientPerformanceTestCompileClasspa org.objenesis:objenesis:3.3=httpClientPerformanceTestRuntimeClasspath,jmhRuntimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath org.openjdk.jmh:jmh-core:1.37=jmhAnnotationProcessor,jmhCompileClasspath,jmhRuntimeClasspath org.openjdk.jmh:jmh-generator-annprocess:1.37=jmhAnnotationProcessor -org.opentest4j:opentest4j:1.3.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.opentest4j:opentest4j:1.3.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath org.osgi:org.osgi.annotation.bundle:2.0.0=httpClientPerformanceTestCompileClasspath,jmhCompileClasspath,testCompileClasspath,testkitCompileClasspath org.osgi:org.osgi.annotation.versioning:1.1.2=httpClientPerformanceTestCompileClasspath,jmhCompileClasspath,testCompileClasspath,testkitCompileClasspath org.osgi:org.osgi.resource:1.0.0=httpClientPerformanceTestCompileClasspath,jmhCompileClasspath,testCompileClasspath,testkitCompileClasspath @@ -199,53 +198,52 @@ org.reactivestreams:reactive-streams:1.0.4=compileClasspath,httpClientPerformanc org.reflections:reflections:0.10.2=checkstyle org.rnorth.duct-tape:duct-tape:1.0.8=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath org.skyscreamer:jsonassert:1.5.3=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.slf4j:jul-to-slf4j:2.0.17=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.slf4j:slf4j-api:2.0.17=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j -org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.boot:spring-boot-http-client:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.boot:spring-boot-http-converter:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.boot:spring-boot-jackson:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.boot:spring-boot-restclient:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.boot:spring-boot-resttestclient:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.boot:spring-boot-servlet:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.boot:spring-boot-starter-jackson:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.boot:spring-boot-starter-logging:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.boot:spring-boot-starter-test:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.boot:spring-boot-starter-tomcat:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.boot:spring-boot-starter-webmvc:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.boot:spring-boot-starter:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.boot:spring-boot-test:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.boot:spring-boot-tomcat:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.boot:spring-boot-web-server:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.boot:spring-boot-webmvc-test:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.boot:spring-boot-webmvc:4.0.0=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.boot:spring-boot:4.0.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.security:spring-security-core:7.0.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.security:spring-security-crypto:7.0.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.security:spring-security-oauth2-client:7.0.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.security:spring-security-oauth2-core:7.0.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.security:spring-security-web:7.0.0=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework:spring-aop:7.0.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework:spring-beans:7.0.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework:spring-context:7.0.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework:spring-core:7.0.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework:spring-expression:7.0.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework:spring-test:7.0.1=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework:spring-web:7.0.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework:spring-webflux:7.0.1=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework:spring-webmvc:7.0.1=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.testcontainers:testcontainers-junit-jupiter:2.0.2=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.testcontainers:testcontainers-toxiproxy:2.0.2=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.testcontainers:testcontainers:2.0.2=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.slf4j:jul-to-slf4j:2.0.18=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.slf4j:slf4j-api:2.0.18=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.18=checkstyle +org.springframework.boot:spring-boot-autoconfigure:4.0.8=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-http-converter:4.0.8=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-jackson:4.0.8=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-resttestclient:4.0.8=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-servlet:4.0.8=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-starter-jackson-test:4.0.8=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-starter-jackson:4.0.8=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-starter-logging:4.0.8=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-starter-test:4.0.8=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.8=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat:4.0.8=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-starter-webmvc-test:4.0.8=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-starter-webmvc:4.0.8=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-starter:4.0.8=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-test-autoconfigure:4.0.8=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-test:4.0.8=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-tomcat:4.0.8=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-web-server:4.0.8=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-webmvc-test:4.0.8=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-webmvc:4.0.8=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot:4.0.8=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.security:spring-security-core:7.0.7=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.security:spring-security-crypto:7.0.7=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.security:spring-security-oauth2-client:7.0.7=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.security:spring-security-oauth2-core:7.0.7=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.security:spring-security-web:7.0.7=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework:spring-aop:7.0.9=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework:spring-beans:7.0.9=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework:spring-context:7.0.9=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework:spring-core:7.0.9=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework:spring-expression:7.0.9=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework:spring-test:7.0.9=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework:spring-web:7.0.9=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework:spring-webflux:7.0.9=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework:spring-webmvc:7.0.9=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.testcontainers:testcontainers-junit-jupiter:2.0.5=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.testcontainers:testcontainers-toxiproxy:2.0.5=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.testcontainers:testcontainers:2.0.5=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs org.xmlunit:xmlunit-core:2.10.4=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath org.yaml:snakeyaml:2.5=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -tools.jackson.core:jackson-core:3.0.2=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -tools.jackson.core:jackson-databind:3.0.2=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -tools.jackson:jackson-bom:3.0.2=httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +tools.jackson.core:jackson-core:3.1.5=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +tools.jackson.core:jackson-databind:3.1.5=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +tools.jackson:jackson-bom:3.1.5=compileClasspath,httpClientPerformanceTestCompileClasspath,httpClientPerformanceTestRuntimeClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath empty= diff --git a/src/adapter/outbound/httpclient/src/httpClientPerformanceTest/java/dev/caskeleton/adapter/outbound/httpclient/performance/OAuthRefreshContentionTest.java b/src/adapter/outbound/httpclient/src/httpClientPerformanceTest/java/dev/caskeleton/adapter/outbound/httpclient/performance/OAuthRefreshContentionTest.java index b3455611..124f76b6 100644 --- a/src/adapter/outbound/httpclient/src/httpClientPerformanceTest/java/dev/caskeleton/adapter/outbound/httpclient/performance/OAuthRefreshContentionTest.java +++ b/src/adapter/outbound/httpclient/src/httpClientPerformanceTest/java/dev/caskeleton/adapter/outbound/httpclient/performance/OAuthRefreshContentionTest.java @@ -42,7 +42,9 @@ class OAuthRefreshContentionTest { AtomicInteger loads = new AtomicInteger(); CountDownLatch started = new CountDownLatch(callers); CountDownLatch release = new CountDownLatch(1); - SingleFlightTokenLoader loader = + // try-with-resources: the loader is AutoCloseable and holds the in-flight refresh map this + // test fills with a hundred callers. + try (SingleFlightTokenLoader loader = new SingleFlightTokenLoader( key -> { loads.incrementAndGet(); @@ -53,43 +55,46 @@ class OAuthRefreshContentionTest { } return new AccessToken( "token", Clock.systemUTC().instant().plus(Duration.ofMinutes(5))); - }); + })) { - ExecutorService pool = Executors.newFixedThreadPool(callers); - try { - List> futures = - IntStream.range(0, callers) - .mapToObj( - index -> - pool.submit( - () -> { - started.countDown(); - return loader.load(KEY); - })) - .toList(); - assertThat(started.await(20, TimeUnit.SECONDS)).isTrue(); - // Wait for the condition the test actually depends on — every caller inside load() — rather - // than sleeping and hoping. `started` only proves each task began; it counts down *before* - // load() is entered, so releasing on a fixed 200ms could let a caller arrive after the first - // refresh had already completed and been removed, producing a second load and a failure that - // looks like a single-flight bug but is a test bug. - long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(20); - while (loader.joinedCallers() < callers && System.nanoTime() < deadline) { - TimeUnit.MILLISECONDS.sleep(1); + ExecutorService pool = Executors.newFixedThreadPool(callers); + try { + List> futures = + IntStream.range(0, callers) + .mapToObj( + index -> + pool.submit( + () -> { + started.countDown(); + return loader.load(KEY); + })) + .toList(); + assertThat(started.await(20, TimeUnit.SECONDS)).isTrue(); + // Wait for the condition the test actually depends on — every caller inside load() — rather + // than sleeping and hoping. `started` only proves each task began; it counts down *before* + // load() is entered, so releasing on a fixed 200ms could let a caller arrive after the + // first + // refresh had already completed and been removed, producing a second load and a failure + // that + // looks like a single-flight bug but is a test bug. + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(20); + while (loader.joinedCallers() < callers && System.nanoTime() < deadline) { + TimeUnit.MILLISECONDS.sleep(1); + } + assertThat(loader.joinedCallers()) + .as("every caller must be inside load() before the refresh is released") + .isEqualTo(callers); + release.countDown(); + for (Future future : futures) { + assertThat(future.get(20, TimeUnit.SECONDS)).isNotNull(); + } + } finally { + pool.shutdownNow(); + assertThat(pool.awaitTermination(10, TimeUnit.SECONDS)).isTrue(); } - assertThat(loader.joinedCallers()) - .as("every caller must be inside load() before the refresh is released") - .isEqualTo(callers); - release.countDown(); - for (Future future : futures) { - assertThat(future.get(20, TimeUnit.SECONDS)).isNotNull(); - } - } finally { - pool.shutdownNow(); - assertThat(pool.awaitTermination(10, TimeUnit.SECONDS)).isTrue(); + + PerformanceAssertions.structural("token refresh collapsed to one request", loads.get() == 1); + assertThat(loader.inFlightRefreshes()).isZero(); } - - PerformanceAssertions.structural("token refresh collapsed to one request", loads.get() == 1); - assertThat(loader.inFlightRefreshes()).isZero(); } } diff --git a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/auth/SingleFlightTokenLoaderTest.java b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/auth/SingleFlightTokenLoaderTest.java index f368a259..19a410d8 100644 --- a/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/auth/SingleFlightTokenLoaderTest.java +++ b/src/adapter/outbound/httpclient/src/test/java/dev/caskeleton/adapter/outbound/httpclient/auth/SingleFlightTokenLoaderTest.java @@ -31,7 +31,9 @@ class SingleFlightTokenLoaderTest { void concurrentRequestsShareOneTokenRefresh() throws Exception { AtomicInteger loads = new AtomicInteger(); CountDownLatch release = new CountDownLatch(1); - SingleFlightTokenLoader loader = + // try-with-resources: the loader owns an in-flight refresh map and is AutoCloseable, and a test + // that leaves one open leaks it into every later test in this class. + try (SingleFlightTokenLoader loader = new SingleFlightTokenLoader( key -> { loads.incrementAndGet(); @@ -42,22 +44,23 @@ class SingleFlightTokenLoaderTest { } return new AccessToken( "token", Clock.systemUTC().instant().plus(Duration.ofMinutes(5))); - }); + })) { - ExecutorService pool = Executors.newFixedThreadPool(20); - try { - List> futures = - IntStream.range(0, 20).mapToObj(index -> pool.submit(() -> loader.load(KEY))).toList(); - Thread.sleep(100); - release.countDown(); - for (Future future : futures) { - assertThat(future.get(5, TimeUnit.SECONDS)).isNotNull(); + ExecutorService pool = Executors.newFixedThreadPool(20); + try { + List> futures = + IntStream.range(0, 20).mapToObj(index -> pool.submit(() -> loader.load(KEY))).toList(); + Thread.sleep(100); + release.countDown(); + for (Future future : futures) { + assertThat(future.get(5, TimeUnit.SECONDS)).isNotNull(); + } + assertThat(loads).hasValue(1); + assertThat(loader.inFlightRefreshes()).isZero(); + } finally { + pool.shutdownNow(); + assertThat(pool.awaitTermination(5, TimeUnit.SECONDS)).isTrue(); } - assertThat(loads).hasValue(1); - assertThat(loader.inFlightRefreshes()).isZero(); - } finally { - pool.shutdownNow(); - assertThat(pool.awaitTermination(5, TimeUnit.SECONDS)).isTrue(); } } diff --git a/src/adapter/outbound/httpclient/src/testkit/java/dev/caskeleton/adapter/outbound/httpclient/testkit/MockHttpServer.java b/src/adapter/outbound/httpclient/src/testkit/java/dev/caskeleton/adapter/outbound/httpclient/testkit/MockHttpServer.java index 4ed166f0..42cce7f7 100644 --- a/src/adapter/outbound/httpclient/src/testkit/java/dev/caskeleton/adapter/outbound/httpclient/testkit/MockHttpServer.java +++ b/src/adapter/outbound/httpclient/src/testkit/java/dev/caskeleton/adapter/outbound/httpclient/testkit/MockHttpServer.java @@ -103,6 +103,11 @@ public final class MockHttpServer implements AutoCloseable { server.enqueue(new MockResponse().setResponseCode(status).setHeader("Location", location)); } + // okio's Buffer is Closeable and its close() is a documented no-op — it holds memory segments, + // not a handle — and MockResponse reads it when the response is served, after this method has + // returned. Closing it here would be both pointless and a use-after-close if it ever stopped + // being a no-op, so the leak analysis is answered rather than obeyed. + @SuppressWarnings("resource") public void enqueueBody(int status, String contentType, byte[] body) { server.enqueue( new MockResponse() diff --git a/src/adapter/outbound/identifier/gradle.lockfile b/src/adapter/outbound/identifier/gradle.lockfile index 75da0cb5..183d0558 100644 --- a/src/adapter/outbound/identifier/gradle.lockfile +++ b/src/adapter/outbound/identifier/gradle.lockfile @@ -2,19 +2,18 @@ # Manual edits can break the build and are not advised. # This file is expected to be part of source control. biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=testCompileClasspath -ch.qos.logback:logback-classic:1.5.21=testCompileClasspath,testRuntimeClasspath -ch.qos.logback:logback-core:1.5.21=testCompileClasspath,testRuntimeClasspath -com.fasterxml.jackson.core:jackson-annotations:2.20=testCompileClasspath,testRuntimeClasspath -com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor +ch.qos.logback:logback-classic:1.5.38=testCompileClasspath,testRuntimeClasspath +ch.qos.logback:logback-core:1.5.38=testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-annotations:2.21=testCompileClasspath,testRuntimeClasspath +com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,testAnnotationProcessor com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs -com.github.spotbugs:spotbugs-annotations:4.8.6=testCompileClasspath com.github.spotbugs:spotbugs:4.10.2=spotbugs com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor -com.google.code.findbugs:jsr305:3.0.2=checkstyle,spotbugs,testCompileClasspath +com.google.code.findbugs:jsr305:3.0.2=checkstyle,spotbugs com.google.code.gson:gson:2.13.2=spotbugs com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor com.google.errorprone:error_prone_annotations:2.38.0=testCompileClasspath @@ -31,24 +30,24 @@ com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=anno com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins -com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath +com.jayway.jsonpath:json-path:2.10.0=testCompileClasspath,testRuntimeClasspath com.puppycrawl.tools:checkstyle:13.5.0=checkstyle com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath commons-beanutils:commons-beanutils:1.11.0=checkstyle commons-collections:commons-collections:3.2.2=checkstyle commons-io:commons-io:2.21.0=spotbugs -commons-logging:commons-logging:1.3.5=testCompileClasspath,testRuntimeClasspath +commons-logging:commons-logging:1.3.6=testCompileClasspath,testRuntimeClasspath info.picocli:picocli:4.7.7=checkstyle io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor io.leangen.geantyref:geantyref:1.3.16=testRuntimeClasspath -io.micrometer:micrometer-commons:1.16.0=testCompileClasspath,testRuntimeClasspath -io.micrometer:micrometer-observation:1.16.0=testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-commons:1.16.7=testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-observation:1.16.7=testCompileClasspath,testRuntimeClasspath jakarta.activation:jakarta.activation-api:2.1.4=testCompileClasspath,testRuntimeClasspath jakarta.annotation:jakarta.annotation-api:3.0.0=testCompileClasspath,testRuntimeClasspath -jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=testCompileClasspath,testRuntimeClasspath +jakarta.xml.bind:jakarta.xml.bind-api:4.0.5=testCompileClasspath,testRuntimeClasspath javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor -jaxen:jaxen:2.0.0=spotbugs +jaxen:jaxen:2.0.6=spotbugs net.bytebuddy:byte-buddy-agent:1.17.8=testCompileClasspath,testRuntimeClasspath net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath net.minidev:accessors-smart:2.6.0=testCompileClasspath,testRuntimeClasspath @@ -59,23 +58,23 @@ org.apache.bcel:bcel:6.12.0=spotbugs org.apache.commons:commons-lang3:3.20.0=checkstyle,spotbugs org.apache.commons:commons-text:1.15.0=spotbugs org.apache.commons:commons-text:1.3=checkstyle -org.apache.groovy:groovy-bom:5.0.2=testCompileClasspath,testRuntimeClasspath -org.apache.groovy:groovy:5.0.2=testCompileClasspath,testRuntimeClasspath +org.apache.groovy:groovy-bom:5.0.8=testCompileClasspath,testRuntimeClasspath +org.apache.groovy:groovy:5.0.8=testCompileClasspath,testRuntimeClasspath org.apache.httpcomponents:httpclient:4.5.13=checkstyle org.apache.httpcomponents:httpcore:4.4.16=checkstyle -org.apache.logging.log4j:log4j-api:2.25.2=spotbugs,testCompileClasspath,testRuntimeClasspath -org.apache.logging.log4j:log4j-core:2.25.2=spotbugs -org.apache.logging.log4j:log4j-to-slf4j:2.25.2=testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-api:2.25.5=spotbugs,testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-core:2.25.5=spotbugs +org.apache.logging.log4j:log4j-to-slf4j:2.25.5=testCompileClasspath,testRuntimeClasspath org.apache.maven.doxia:doxia-core:1.12.0=checkstyle org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle -org.apache.tomcat.embed:tomcat-embed-core:11.0.14=testCompileClasspath,testRuntimeClasspath -org.apache.tomcat.embed:tomcat-embed-el:11.0.14=testCompileClasspath,testRuntimeClasspath -org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-core:11.0.24=testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-el:11.0.24=testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-websocket:11.0.24=testCompileClasspath,testRuntimeClasspath org.apache.xbean:xbean-reflect:3.7=checkstyle org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath -org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath +org.assertj:assertj-core:3.27.7=testCompileClasspath,testRuntimeClasspath org.awaitility:awaitility:4.3.0=testCompileClasspath,testRuntimeClasspath org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle @@ -84,15 +83,15 @@ org.codehaus.plexus:plexus-utils:3.3.0=checkstyle org.dom4j:dom4j:2.2.0=spotbugs org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath org.javassist:javassist:3.28.0-GA=checkstyle -org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath -org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-engine:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath -org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath +org.jspecify:jspecify:1.0.1=annotationProcessor,checkstyle,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.3=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.3=testRuntimeClasspath +org.junit:junit-bom:6.0.3=testCompileClasspath,testRuntimeClasspath org.junit:junit-bom:6.1.0=spotbugs org.mockito:mockito-core:5.20.0=mockitoAgent,testCompileClasspath,testRuntimeClasspath org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath @@ -111,46 +110,45 @@ org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor org.reflections:reflections:0.10.2=checkstyle org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath -org.slf4j:jul-to-slf4j:2.0.17=testCompileClasspath,testRuntimeClasspath -org.slf4j:slf4j-api:2.0.17=spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath -org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j +org.slf4j:jul-to-slf4j:2.0.18=testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-api:2.0.18=spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.18=checkstyle org.spockframework:spock-bom:2.4-groovy-5.0=testCompileClasspath,testRuntimeClasspath org.spockframework:spock-core:2.4-groovy-5.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-http-converter:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-restclient:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-servlet:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-logging:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-web-server:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework:spring-aop:7.0.1=testCompileClasspath,testRuntimeClasspath -org.springframework:spring-beans:7.0.1=testCompileClasspath,testRuntimeClasspath -org.springframework:spring-context:7.0.1=testCompileClasspath,testRuntimeClasspath -org.springframework:spring-core:7.0.1=testCompileClasspath,testRuntimeClasspath -org.springframework:spring-expression:7.0.1=testCompileClasspath,testRuntimeClasspath -org.springframework:spring-test:7.0.1=testCompileClasspath,testRuntimeClasspath -org.springframework:spring-web:7.0.1=testCompileClasspath,testRuntimeClasspath -org.springframework:spring-webmvc:7.0.1=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-autoconfigure:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-http-converter:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-jackson:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-resttestclient:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-servlet:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-jackson-test:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-jackson:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-logging:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-test:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-webmvc-test:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-webmvc:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-test-autoconfigure:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-test:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-tomcat:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-web-server:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-webmvc-test:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-webmvc:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework:spring-aop:7.0.9=testCompileClasspath,testRuntimeClasspath +org.springframework:spring-beans:7.0.9=testCompileClasspath,testRuntimeClasspath +org.springframework:spring-context:7.0.9=testCompileClasspath,testRuntimeClasspath +org.springframework:spring-core:7.0.9=testCompileClasspath,testRuntimeClasspath +org.springframework:spring-expression:7.0.9=testCompileClasspath,testRuntimeClasspath +org.springframework:spring-test:7.0.9=testCompileClasspath,testRuntimeClasspath +org.springframework:spring-web:7.0.9=testCompileClasspath,testRuntimeClasspath +org.springframework:spring-webmvc:7.0.9=testCompileClasspath,testRuntimeClasspath org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath org.yaml:snakeyaml:2.5=testCompileClasspath,testRuntimeClasspath -tools.jackson.core:jackson-core:3.0.2=testCompileClasspath,testRuntimeClasspath -tools.jackson.core:jackson-databind:3.0.2=testCompileClasspath,testRuntimeClasspath -tools.jackson:jackson-bom:3.0.2=testCompileClasspath,testRuntimeClasspath +tools.jackson.core:jackson-core:3.1.5=testCompileClasspath,testRuntimeClasspath +tools.jackson.core:jackson-databind:3.1.5=testCompileClasspath,testRuntimeClasspath +tools.jackson:jackson-bom:3.1.5=testCompileClasspath,testRuntimeClasspath empty=compileClasspath,runtimeClasspath diff --git a/src/adapter/outbound/messaging/gradle.lockfile b/src/adapter/outbound/messaging/gradle.lockfile index ff003ccc..1126ebf2 100644 --- a/src/adapter/outbound/messaging/gradle.lockfile +++ b/src/adapter/outbound/messaging/gradle.lockfile @@ -2,20 +2,19 @@ # Manual edits can break the build and are not advised. # This file is expected to be part of source control. biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,testCompileClasspath -ch.qos.logback:logback-classic:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -ch.qos.logback:logback-core:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +ch.qos.logback:logback-classic:1.5.38=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +ch.qos.logback:logback-core:1.5.38=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath com.ethlo.time:itu:1.14.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.fasterxml.jackson.core:jackson-annotations:2.20=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor +com.fasterxml.jackson.core:jackson-annotations:2.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,testAnnotationProcessor com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs -com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,testCompileClasspath com.github.spotbugs:spotbugs:4.10.2=spotbugs com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor -com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,spotbugs,testCompileClasspath +com.google.code.findbugs:jsr305:3.0.2=checkstyle,spotbugs com.google.code.gson:gson:2.13.2=spotbugs com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,testCompileClasspath @@ -32,24 +31,24 @@ com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=anno com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins -com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath +com.jayway.jsonpath:json-path:2.10.0=testCompileClasspath,testRuntimeClasspath com.networknt:json-schema-validator:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath com.puppycrawl.tools:checkstyle:13.5.0=checkstyle com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath commons-beanutils:commons-beanutils:1.11.0=checkstyle commons-collections:commons-collections:3.2.2=checkstyle commons-io:commons-io:2.21.0=spotbugs -commons-logging:commons-logging:1.3.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +commons-logging:commons-logging:1.3.6=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath info.picocli:picocli:4.7.7=checkstyle io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor -io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-commons:1.16.7=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-observation:1.16.7=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath jakarta.activation:jakarta.activation-api:2.1.4=testCompileClasspath,testRuntimeClasspath jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=testCompileClasspath,testRuntimeClasspath +jakarta.xml.bind:jakarta.xml.bind-api:4.0.5=testCompileClasspath,testRuntimeClasspath javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor -jaxen:jaxen:2.0.0=spotbugs +jaxen:jaxen:2.0.6=spotbugs net.bytebuddy:byte-buddy-agent:1.17.8=testCompileClasspath,testRuntimeClasspath net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath net.minidev:accessors-smart:2.6.0=testCompileClasspath,testRuntimeClasspath @@ -62,19 +61,19 @@ org.apache.commons:commons-text:1.15.0=spotbugs org.apache.commons:commons-text:1.3=checkstyle org.apache.httpcomponents:httpclient:4.5.13=checkstyle org.apache.httpcomponents:httpcore:4.4.16=checkstyle -org.apache.logging.log4j:log4j-api:2.25.2=compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath -org.apache.logging.log4j:log4j-core:2.25.2=spotbugs -org.apache.logging.log4j:log4j-to-slf4j:2.25.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-api:2.25.5=compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-core:2.25.5=spotbugs +org.apache.logging.log4j:log4j-to-slf4j:2.25.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.apache.maven.doxia:doxia-core:1.12.0=checkstyle org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle -org.apache.tomcat.embed:tomcat-embed-core:11.0.14=testCompileClasspath,testRuntimeClasspath -org.apache.tomcat.embed:tomcat-embed-el:11.0.14=testCompileClasspath,testRuntimeClasspath -org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-core:11.0.24=testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-el:11.0.24=testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-websocket:11.0.24=testCompileClasspath,testRuntimeClasspath org.apache.xbean:xbean-reflect:3.7=checkstyle org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath -org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath +org.assertj:assertj-core:3.27.7=testCompileClasspath,testRuntimeClasspath org.awaitility:awaitility:4.3.0=testCompileClasspath,testRuntimeClasspath org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle @@ -83,15 +82,15 @@ org.codehaus.plexus:plexus-utils:3.3.0=checkstyle org.dom4j:dom4j:2.2.0=spotbugs org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath org.javassist:javassist:3.28.0-GA=checkstyle -org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath -org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-engine:6.0.1=testRuntimeClasspath -org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath -org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath +org.jspecify:jspecify:1.0.1=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.3=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.3=testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.3=testRuntimeClasspath +org.junit:junit-bom:6.0.3=testCompileClasspath,testRuntimeClasspath org.junit:junit-bom:6.1.0=spotbugs org.mockito:mockito-core:5.20.0=mockitoAgent,testCompileClasspath,testRuntimeClasspath org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath @@ -110,45 +109,44 @@ org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor org.reflections:reflections:0.10.2=checkstyle org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath -org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath -org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j -org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-configuration-processor:4.0.0=annotationProcessor -org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-http-converter:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-jackson:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-restclient:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-servlet:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-json:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-web-server:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-aop:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-beans:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-context:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-core:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-expression:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-test:7.0.1=testCompileClasspath,testRuntimeClasspath -org.springframework:spring-web:7.0.1=testCompileClasspath,testRuntimeClasspath -org.springframework:spring-webmvc:7.0.1=testCompileClasspath,testRuntimeClasspath +org.slf4j:jul-to-slf4j:2.0.18=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-api:2.0.18=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.18=checkstyle +org.springframework.boot:spring-boot-autoconfigure:4.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-configuration-processor:4.0.8=annotationProcessor +org.springframework.boot:spring-boot-http-converter:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-jackson:4.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-resttestclient:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-servlet:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-jackson-test:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-jackson:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-json:4.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-logging:4.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-test:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-webmvc-test:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-webmvc:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter:4.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-test-autoconfigure:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-test:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-tomcat:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-web-server:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-webmvc-test:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-webmvc:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot:4.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-aop:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-beans:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-context:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-core:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-expression:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-test:7.0.9=testCompileClasspath,testRuntimeClasspath +org.springframework:spring-web:7.0.9=testCompileClasspath,testRuntimeClasspath +org.springframework:spring-webmvc:7.0.9=testCompileClasspath,testRuntimeClasspath org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath -tools.jackson.core:jackson-core:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -tools.jackson.core:jackson-databind:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -tools.jackson:jackson-bom:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +tools.jackson.core:jackson-core:3.1.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +tools.jackson.core:jackson-databind:3.1.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +tools.jackson:jackson-bom:3.1.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath empty= diff --git a/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/realtime/MessagingDurableFanoutAdapter.java b/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/realtime/MessagingDurableFanoutAdapter.java new file mode 100644 index 00000000..45f416a5 --- /dev/null +++ b/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/realtime/MessagingDurableFanoutAdapter.java @@ -0,0 +1,60 @@ +package dev.caskeleton.adapter.outbound.messaging.realtime; + +import dev.caskeleton.adapter.outbound.messaging.core.MessageBroker; +import dev.caskeleton.adapter.outbound.messaging.core.OutboundMessage; +import dev.caskeleton.application.realtime.DurableFanoutPort; +import dev.caskeleton.application.realtime.DurableFanoutRecord; +import dev.caskeleton.application.realtime.DurableFanoutUnavailableException; +import java.util.Objects; + +/** + * Durable cross-node fan-out, on the messaging platform. + * + *

Fail-closed, unlike the ephemeral fan-out beside it. A caller reaching for this port has + * something a user will be told happened, so "the broker did not accept it" has to reach the caller + * — it is the only party that can decide between retrying, dropping and falling back. + * + *

The partition key is the record's, and it is the recipient rather than the channel. Keying on + * the channel would put a whole feed in one partition, which serialises every recipient behind the + * slowest one and quietly removes the parallelism the broker was chosen for. Keying on the + * recipient also gives per-recipient ordering, which is the only ordering guarantee this fan-out + * makes and the only one its consumers need. + * + *

Nothing here deduplicates. Delivery is at-least-once by construction and the receiver holds + * the deduplication state, because it is the only side that knows what it has already applied. + */ +public final class MessagingDurableFanoutAdapter implements DurableFanoutPort { + + private final MessageBroker broker; + private final String topic; + + /** + * Creates the adapter. + * + * @param broker the active broker binding + * @param topic the topic every realtime fan-out record is published to + */ + public MessagingDurableFanoutAdapter(MessageBroker broker, String topic) { + this.broker = Objects.requireNonNull(broker, "broker must not be null"); + this.topic = Objects.requireNonNull(topic, "topic must not be null"); + if (topic.isBlank()) { + throw new IllegalArgumentException("a blank topic is not a destination"); + } + } + + @Override + public void publish(DurableFanoutRecord record) { + Objects.requireNonNull(record, "record must not be null"); + OutboundMessage message = + new OutboundMessage( + topic, record.partitionKey(), RealtimeFanoutEnvelopeJson.toJson(record)); + try { + broker.send(message); + } catch (Exception failure) { + // Wrapped rather than propagated raw: the broker's own exception names its client library, + // and a caller in a transport adapter should not have to catch a Kafka type to know its + // publish failed. + throw new DurableFanoutUnavailableException(record.channel(), failure); + } + } +} diff --git a/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/realtime/RealtimeFanoutEnvelopeJson.java b/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/realtime/RealtimeFanoutEnvelopeJson.java new file mode 100644 index 00000000..b9c2da01 --- /dev/null +++ b/src/adapter/outbound/messaging/src/main/java/dev/caskeleton/adapter/outbound/messaging/realtime/RealtimeFanoutEnvelopeJson.java @@ -0,0 +1,78 @@ +package dev.caskeleton.adapter.outbound.messaging.realtime; + +import dev.caskeleton.application.realtime.DurableFanoutRecord; +import java.util.Objects; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; +import tools.jackson.databind.node.ObjectNode; + +/** + * The wire form of a durable fan-out record. + * + *

Carries the expiry and the ordering pair alongside the payload rather than leaving them to + * broker headers. Headers are the tempting place — they are cheaper and they survive a payload + * schema change — and they are wrong here for one reason: a broker migration, a bridge, or a + * dead-letter round trip preserves the body and loses the headers. A record whose expiry lived in a + * header would arrive from a dead-letter queue looking permanently fresh. + * + *

The reader tolerates unknown fields and refuses missing ones. During a rolling deploy both + * versions are publishing, so a reader that rejected an added field would fail on everything the + * newer nodes emit, for the whole rollout. + */ +public final class RealtimeFanoutEnvelopeJson { + + private static final String VERSION = "1"; + private static final ObjectMapper MAPPER = JsonMapper.builder().build(); + + private RealtimeFanoutEnvelopeJson() {} + + /** Renders a record for the broker. */ + public static String toJson(DurableFanoutRecord record) { + Objects.requireNonNull(record, "record must not be null"); + ObjectNode root = MAPPER.createObjectNode(); + root.put("v", VERSION); + root.put("channel", record.channel().value()); + root.put("partitionKey", record.partitionKey()); + root.put("payload", record.payload()); + root.put("publishedAt", record.publishedAt().toEpochMilli()); + root.put("expiresAt", record.expiresAt().toEpochMilli()); + record.streamId().ifPresent(streamId -> root.put("streamId", streamId)); + record.position().ifPresent(position -> root.put("position", position)); + return root.toString(); + } + + /** + * Reads a record back. + * + * @throws IllegalArgumentException when the document is not a record this version can read + */ + public static DurableFanoutRecord fromJson(String json) { + Objects.requireNonNull(json, "json must not be null"); + var root = MAPPER.readTree(json); + if (!root.isObject() || !VERSION.equals(text(root, "v"))) { + throw new IllegalArgumentException("not a version " + VERSION + " realtime fan-out envelope"); + } + var streamId = root.get("streamId"); + var position = root.get("position"); + return new DurableFanoutRecord( + new dev.caskeleton.application.realtime.RealtimeChannel(text(root, "channel")), + text(root, "partitionKey"), + text(root, "payload"), + streamId == null || streamId.isNull() + ? java.util.Optional.empty() + : java.util.Optional.of(streamId.asString()), + position == null || position.isNull() + ? java.util.Optional.empty() + : java.util.Optional.of(position.asLong()), + java.time.Instant.ofEpochMilli(root.get("publishedAt").asLong()), + java.time.Instant.ofEpochMilli(root.get("expiresAt").asLong())); + } + + private static String text(tools.jackson.databind.JsonNode root, String field) { + var node = root.get(field); + if (node == null || node.isNull()) { + throw new IllegalArgumentException("a realtime fan-out envelope must carry " + field); + } + return node.asString(); + } +} diff --git a/src/adapter/outbound/notification/gradle.lockfile b/src/adapter/outbound/notification/gradle.lockfile index d59942f4..b30c50f9 100644 --- a/src/adapter/outbound/notification/gradle.lockfile +++ b/src/adapter/outbound/notification/gradle.lockfile @@ -2,20 +2,19 @@ # Manual edits can break the build and are not advised. # This file is expected to be part of source control. biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,testCompileClasspath -ch.qos.logback:logback-classic:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -ch.qos.logback:logback-core:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +ch.qos.logback:logback-classic:1.5.38=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +ch.qos.logback:logback-core:1.5.38=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath com.ethlo.time:itu:1.14.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.fasterxml.jackson.core:jackson-annotations:2.20=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor +com.fasterxml.jackson.core:jackson-annotations:2.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,testAnnotationProcessor com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs -com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,testCompileClasspath com.github.spotbugs:spotbugs:4.10.2=spotbugs com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor -com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,spotbugs,testCompileClasspath +com.google.code.findbugs:jsr305:3.0.2=checkstyle,spotbugs com.google.code.gson:gson:2.13.2=spotbugs com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,testCompileClasspath @@ -32,27 +31,27 @@ com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=anno com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins -com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath +com.jayway.jsonpath:json-path:2.10.0=testCompileClasspath,testRuntimeClasspath com.networknt:json-schema-validator:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath com.puppycrawl.tools:checkstyle:13.5.0=checkstyle com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath commons-beanutils:commons-beanutils:1.11.0=checkstyle commons-collections:commons-collections:3.2.2=checkstyle commons-io:commons-io:2.21.0=spotbugs -commons-logging:commons-logging:1.3.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +commons-logging:commons-logging:1.3.6=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath info.picocli:picocli:4.7.7=checkstyle io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor -io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.projectreactor:reactor-core:3.8.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.projectreactor:reactor-test:3.8.0=testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-commons:1.16.7=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-observation:1.16.7=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.projectreactor:reactor-core:3.8.7=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.projectreactor:reactor-test:3.8.7=testCompileClasspath,testRuntimeClasspath jakarta.activation:jakarta.activation-api:2.1.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath jakarta.mail:jakarta.mail-api:2.1.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=testCompileClasspath,testRuntimeClasspath +jakarta.xml.bind:jakarta.xml.bind-api:4.0.5=testCompileClasspath,testRuntimeClasspath javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor -jaxen:jaxen:2.0.0=spotbugs +jaxen:jaxen:2.0.6=spotbugs net.bytebuddy:byte-buddy-agent:1.17.8=testCompileClasspath,testRuntimeClasspath net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath net.minidev:accessors-smart:2.6.0=testCompileClasspath,testRuntimeClasspath @@ -66,19 +65,19 @@ org.apache.commons:commons-text:1.15.0=spotbugs org.apache.commons:commons-text:1.3=checkstyle org.apache.httpcomponents:httpclient:4.5.13=checkstyle org.apache.httpcomponents:httpcore:4.4.16=checkstyle -org.apache.logging.log4j:log4j-api:2.25.2=compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath -org.apache.logging.log4j:log4j-core:2.25.2=spotbugs -org.apache.logging.log4j:log4j-to-slf4j:2.25.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-api:2.25.5=compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-core:2.25.5=spotbugs +org.apache.logging.log4j:log4j-to-slf4j:2.25.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.apache.maven.doxia:doxia-core:1.12.0=checkstyle org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle -org.apache.tomcat.embed:tomcat-embed-core:11.0.14=testCompileClasspath,testRuntimeClasspath -org.apache.tomcat.embed:tomcat-embed-el:11.0.14=testCompileClasspath,testRuntimeClasspath -org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-core:11.0.24=testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-el:11.0.24=testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-websocket:11.0.24=testCompileClasspath,testRuntimeClasspath org.apache.xbean:xbean-reflect:3.7=checkstyle org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath -org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath +org.assertj:assertj-core:3.27.7=testCompileClasspath,testRuntimeClasspath org.attoparser:attoparser:2.0.7.RELEASE=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.awaitility:awaitility:4.3.0=testCompileClasspath,testRuntimeClasspath org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle @@ -91,15 +90,15 @@ org.eclipse.angus:angus-mail:2.0.5=runtimeClasspath,testRuntimeClasspath org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath org.javassist:javassist:3.28.0-GA=checkstyle org.javassist:javassist:3.29.0-GA=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath -org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-engine:6.0.1=testRuntimeClasspath -org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath -org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath +org.jspecify:jspecify:1.0.1=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.3=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.3=testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.3=testRuntimeClasspath +org.junit:junit-bom:6.0.3=testCompileClasspath,testRuntimeClasspath org.junit:junit-bom:6.1.0=spotbugs org.mockito:mockito-core:5.20.0=mockitoAgent,testCompileClasspath,testRuntimeClasspath org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath @@ -119,51 +118,50 @@ org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor org.reactivestreams:reactive-streams:1.0.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.reflections:reflections:0.10.2=checkstyle org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath -org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath -org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j -org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-configuration-processor:4.0.0=annotationProcessor -org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-http-converter:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-jackson:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-mail:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-restclient:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-servlet:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-json:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-mail:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-web-server:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-aop:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-beans:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-context-support:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-context:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-core:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-expression:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-test:7.0.1=testCompileClasspath,testRuntimeClasspath -org.springframework:spring-web:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-webmvc:7.0.1=testCompileClasspath,testRuntimeClasspath -org.thymeleaf:thymeleaf:3.1.3.RELEASE=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.slf4j:jul-to-slf4j:2.0.18=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-api:2.0.18=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.18=checkstyle +org.springframework.boot:spring-boot-autoconfigure:4.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-configuration-processor:4.0.8=annotationProcessor +org.springframework.boot:spring-boot-http-converter:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-jackson:4.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-mail:4.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-resttestclient:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-servlet:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-jackson-test:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-jackson:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-json:4.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-logging:4.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-mail:4.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-test:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-webmvc-test:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-webmvc:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter:4.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-test-autoconfigure:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-test:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-tomcat:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-web-server:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-webmvc-test:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-webmvc:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot:4.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-aop:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-beans:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-context-support:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-context:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-core:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-expression:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-test:7.0.9=testCompileClasspath,testRuntimeClasspath +org.springframework:spring-web:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-webmvc:7.0.9=testCompileClasspath,testRuntimeClasspath +org.thymeleaf:thymeleaf:3.1.5.RELEASE=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.unbescape:unbescape:1.1.6.RELEASE=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath org.yaml:snakeyaml:2.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -tools.jackson.core:jackson-core:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -tools.jackson.core:jackson-databind:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -tools.jackson:jackson-bom:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +tools.jackson.core:jackson-core:3.1.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +tools.jackson.core:jackson-databind:3.1.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +tools.jackson:jackson-bom:3.1.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath empty= diff --git a/src/adapter/outbound/objectstorage/gradle.lockfile b/src/adapter/outbound/objectstorage/gradle.lockfile index 93c2ce47..493644f4 100644 --- a/src/adapter/outbound/objectstorage/gradle.lockfile +++ b/src/adapter/outbound/objectstorage/gradle.lockfile @@ -2,22 +2,21 @@ # Manual edits can break the build and are not advised. # This file is expected to be part of source control. biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=objectStorageAwsQualificationTestCompileClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioFaultTestCompileClasspath,testCompileClasspath -ch.qos.logback:logback-classic:1.5.21=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -ch.qos.logback:logback-core:1.5.21=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -com.fasterxml.jackson.core:jackson-annotations:2.20=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,objectStorageAwsQualificationTestAnnotationProcessor,objectStorageMinioContractTestAnnotationProcessor,objectStorageMinioFaultTestAnnotationProcessor,testAnnotationProcessor -com.github.docker-java:docker-java-api:3.7.0=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -com.github.docker-java:docker-java-transport-zerodep:3.7.0=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -com.github.docker-java:docker-java-transport:3.7.0=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +ch.qos.logback:logback-classic:1.5.38=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +ch.qos.logback:logback-core:1.5.38=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-annotations:2.21=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,objectStorageAwsQualificationTestAnnotationProcessor,objectStorageMinioContractTestAnnotationProcessor,objectStorageMinioFaultTestAnnotationProcessor,testAnnotationProcessor +com.github.docker-java:docker-java-api:3.7.1=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.github.docker-java:docker-java-transport-zerodep:3.7.1=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.github.docker-java:docker-java-transport:3.7.1=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,objectStorageAwsQualificationTestAnnotationProcessor,objectStorageMinioContractTestAnnotationProcessor,objectStorageMinioFaultTestAnnotationProcessor,testAnnotationProcessor com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs -com.github.spotbugs:spotbugs-annotations:4.8.6=objectStorageAwsQualificationTestCompileClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioFaultTestCompileClasspath,testCompileClasspath com.github.spotbugs:spotbugs:4.10.2=spotbugs com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,objectStorageAwsQualificationTestAnnotationProcessor,objectStorageMinioContractTestAnnotationProcessor,objectStorageMinioFaultTestAnnotationProcessor,testAnnotationProcessor com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,objectStorageAwsQualificationTestAnnotationProcessor,objectStorageMinioContractTestAnnotationProcessor,objectStorageMinioFaultTestAnnotationProcessor,testAnnotationProcessor com.google.auto:auto-common:1.2.2=annotationProcessor,objectStorageAwsQualificationTestAnnotationProcessor,objectStorageMinioContractTestAnnotationProcessor,objectStorageMinioFaultTestAnnotationProcessor,testAnnotationProcessor -com.google.code.findbugs:jsr305:3.0.2=checkstyle,objectStorageAwsQualificationTestCompileClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioFaultTestCompileClasspath,spotbugs,testCompileClasspath +com.google.code.findbugs:jsr305:3.0.2=checkstyle,spotbugs com.google.code.gson:gson:2.13.2=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,objectStorageAwsQualificationTestAnnotationProcessor,objectStorageMinioContractTestAnnotationProcessor,objectStorageMinioFaultTestAnnotationProcessor,testAnnotationProcessor com.google.errorprone:error_prone_annotations:2.41.0=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath @@ -33,7 +32,7 @@ com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=anno com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,objectStorageAwsQualificationTestAnnotationProcessor,objectStorageMinioContractTestAnnotationProcessor,objectStorageMinioFaultTestAnnotationProcessor,testAnnotationProcessor com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,objectStorageAwsQualificationTestAnnotationProcessor,objectStorageMinioContractTestAnnotationProcessor,objectStorageMinioFaultTestAnnotationProcessor,testAnnotationProcessor com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins -com.jayway.jsonpath:json-path:2.9.0=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.jayway.jsonpath:json-path:2.10.0=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.puppycrawl.tools:checkstyle:13.5.0=checkstyle com.tngtech.archunit:archunit-junit5-api:1.3.0=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.tngtech.archunit:archunit-junit5-engine-api:1.3.0=objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestRuntimeClasspath,testRuntimeClasspath @@ -46,13 +45,13 @@ commons-codec:commons-codec:1.19.0=objectStorageAwsQualificationTestCompileClass commons-collections:commons-collections:3.2.2=checkstyle commons-io:commons-io:2.20.0=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath commons-io:commons-io:2.21.0=spotbugs -commons-logging:commons-logging:1.3.5=compileClasspath,objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +commons-logging:commons-logging:1.3.6=compileClasspath,objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath eu.rekawek.toxiproxy:toxiproxy-java:2.1.11=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath info.picocli:picocli:4.7.7=checkstyle io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,objectStorageAwsQualificationTestAnnotationProcessor,objectStorageMinioContractTestAnnotationProcessor,objectStorageMinioFaultTestAnnotationProcessor,testAnnotationProcessor io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,objectStorageAwsQualificationTestAnnotationProcessor,objectStorageMinioContractTestAnnotationProcessor,objectStorageMinioFaultTestAnnotationProcessor,testAnnotationProcessor -io.micrometer:micrometer-commons:1.16.0=compileClasspath,objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.micrometer:micrometer-observation:1.16.0=compileClasspath,objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-commons:1.16.7=compileClasspath,objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-observation:1.16.7=compileClasspath,objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.netty:netty-buffer:4.2.17.Final=compileClasspath,objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.netty:netty-codec-base:4.2.17.Final=compileClasspath,objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.netty:netty-codec-compression:4.2.17.Final=compileClasspath,objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath @@ -69,9 +68,9 @@ io.netty:netty-transport-native-unix-common:4.2.17.Final=compileClasspath,object io.netty:netty-transport:4.2.17.Final=compileClasspath,objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath jakarta.activation:jakarta.activation-api:2.1.4=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath jakarta.annotation:jakarta.annotation-api:3.0.0=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.xml.bind:jakarta.xml.bind-api:4.0.5=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath javax.inject:javax.inject:1=annotationProcessor,objectStorageAwsQualificationTestAnnotationProcessor,objectStorageMinioContractTestAnnotationProcessor,objectStorageMinioFaultTestAnnotationProcessor,testAnnotationProcessor -jaxen:jaxen:2.0.0=spotbugs +jaxen:jaxen:2.0.6=spotbugs net.bytebuddy:byte-buddy-agent:1.17.8=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath net.bytebuddy:byte-buddy:1.17.8=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath net.java.dev.jna:jna:5.18.1=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath @@ -91,19 +90,19 @@ org.apache.commons:commons-text:1.15.0=spotbugs org.apache.commons:commons-text:1.3=checkstyle org.apache.httpcomponents:httpclient:4.5.13=checkstyle,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath org.apache.httpcomponents:httpcore:4.4.16=checkstyle,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.apache.logging.log4j:log4j-api:2.25.2=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath -org.apache.logging.log4j:log4j-core:2.25.2=spotbugs -org.apache.logging.log4j:log4j-to-slf4j:2.25.2=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-api:2.25.5=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-core:2.25.5=spotbugs +org.apache.logging.log4j:log4j-to-slf4j:2.25.5=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.apache.maven.doxia:doxia-core:1.12.0=checkstyle org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle -org.apache.tomcat.embed:tomcat-embed-core:11.0.14=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.apache.tomcat.embed:tomcat-embed-el:11.0.14=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-core:11.0.24=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-el:11.0.24=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-websocket:11.0.24=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.apache.xbean:xbean-reflect:3.7=checkstyle org.apiguardian:apiguardian-api:1.1.2=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.assertj:assertj-core:3.27.6=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.assertj:assertj-core:3.27.7=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.awaitility:awaitility:4.3.0=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle @@ -113,15 +112,15 @@ org.dom4j:dom4j:2.2.0=spotbugs org.hamcrest:hamcrest:3.0=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.javassist:javassist:3.28.0-GA=checkstyle org.jetbrains:annotations:17.0.0=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,objectStorageAwsQualificationTestAnnotationProcessor,objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestAnnotationProcessor,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestAnnotationProcessor,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-api:6.0.1=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-engine:6.0.1=objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestRuntimeClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-params:6.0.1=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter:6.0.1=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-commons:6.0.1=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-engine:6.0.1=objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestRuntimeClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-launcher:6.0.1=objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestRuntimeClasspath,testRuntimeClasspath -org.junit:junit-bom:6.0.1=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.jspecify:jspecify:1.0.1=annotationProcessor,checkstyle,compileClasspath,objectStorageAwsQualificationTestAnnotationProcessor,objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestAnnotationProcessor,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestAnnotationProcessor,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:6.0.3=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.3=objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestRuntimeClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.3=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.3=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.3=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.3=objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestRuntimeClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.3=objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestRuntimeClasspath,testRuntimeClasspath +org.junit:junit-bom:6.0.3=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.junit:junit-bom:6.1.0=spotbugs org.mockito:mockito-core:5.20.0=mockitoAgent,objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.mockito:mockito-junit-jupiter:5.20.0=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath @@ -142,44 +141,43 @@ org.reactivestreams:reactive-streams:1.0.4=compileClasspath,objectStorageAwsQual org.reflections:reflections:0.10.2=checkstyle org.rnorth.duct-tape:duct-tape:1.0.8=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.skyscreamer:jsonassert:1.5.3=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.slf4j:jul-to-slf4j:2.0.17=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.slf4j:slf4j-api:2.0.17=compileClasspath,objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath -org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j -org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-configuration-processor:4.0.0=annotationProcessor -org.springframework.boot:spring-boot-http-client:4.0.0=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-http-converter:4.0.0=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-jackson:4.0.0=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-restclient:4.0.0=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-resttestclient:4.0.0=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-servlet:4.0.0=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-jackson:4.0.0=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-logging:4.0.0=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-test:4.0.0=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-tomcat:4.0.0=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-webmvc:4.0.0=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter:4.0.0=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-test:4.0.0=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-tomcat:4.0.0=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-web-server:4.0.0=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-webmvc-test:4.0.0=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-webmvc:4.0.0=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot:4.0.0=compileClasspath,objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-aop:7.0.1=compileClasspath,objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-beans:7.0.1=compileClasspath,objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-context:7.0.1=compileClasspath,objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-core:7.0.1=compileClasspath,objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-expression:7.0.1=compileClasspath,objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-test:7.0.1=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-web:7.0.1=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-webmvc:7.0.1=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.testcontainers:testcontainers-junit-jupiter:2.0.2=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.testcontainers:testcontainers-toxiproxy:2.0.2=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.testcontainers:testcontainers:2.0.2=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.slf4j:jul-to-slf4j:2.0.18=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-api:2.0.18=compileClasspath,objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.18=checkstyle +org.springframework.boot:spring-boot-autoconfigure:4.0.8=compileClasspath,objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-configuration-processor:4.0.8=annotationProcessor +org.springframework.boot:spring-boot-http-converter:4.0.8=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-jackson:4.0.8=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-resttestclient:4.0.8=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-servlet:4.0.8=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-jackson-test:4.0.8=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-jackson:4.0.8=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-logging:4.0.8=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-test:4.0.8=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.8=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat:4.0.8=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-webmvc-test:4.0.8=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-webmvc:4.0.8=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter:4.0.8=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-test-autoconfigure:4.0.8=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-test:4.0.8=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-tomcat:4.0.8=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-web-server:4.0.8=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-webmvc-test:4.0.8=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-webmvc:4.0.8=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot:4.0.8=compileClasspath,objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-aop:7.0.9=compileClasspath,objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-beans:7.0.9=compileClasspath,objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-context:7.0.9=compileClasspath,objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-core:7.0.9=compileClasspath,objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-expression:7.0.9=compileClasspath,objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-test:7.0.9=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-web:7.0.9=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-webmvc:7.0.9=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.testcontainers:testcontainers-junit-jupiter:2.0.5=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.testcontainers:testcontainers-toxiproxy:2.0.5=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.testcontainers:testcontainers:2.0.5=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs org.xmlunit:xmlunit-core:2.10.4=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.yaml:snakeyaml:2.5=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath @@ -213,7 +211,7 @@ software.amazon.awssdk:sdk-core:2.30.0=compileClasspath,objectStorageAwsQualific software.amazon.awssdk:third-party-jackson-core:2.30.0=compileClasspath,objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath software.amazon.awssdk:utils:2.30.0=compileClasspath,objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath software.amazon.eventstream:eventstream:1.0.1=compileClasspath,objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -tools.jackson.core:jackson-core:3.0.2=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -tools.jackson.core:jackson-databind:3.0.2=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -tools.jackson:jackson-bom:3.0.2=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +tools.jackson.core:jackson-core:3.1.5=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +tools.jackson.core:jackson-databind:3.1.5=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +tools.jackson:jackson-bom:3.1.5=objectStorageAwsQualificationTestCompileClasspath,objectStorageAwsQualificationTestRuntimeClasspath,objectStorageMinioContractTestCompileClasspath,objectStorageMinioContractTestRuntimeClasspath,objectStorageMinioFaultTestCompileClasspath,objectStorageMinioFaultTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath empty= diff --git a/src/adapter/outbound/persistence-jpa/gradle.lockfile b/src/adapter/outbound/persistence-jpa/gradle.lockfile index 1d80bd48..8a34d2fd 100644 --- a/src/adapter/outbound/persistence-jpa/gradle.lockfile +++ b/src/adapter/outbound/persistence-jpa/gradle.lockfile @@ -2,26 +2,25 @@ # Manual edits can break the build and are not advised. # This file is expected to be part of source control. biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,postgresqlIntegrationTestCompileClasspath,testCompileClasspath,testkitCompileClasspath -ch.qos.logback:logback-classic:1.5.21=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -ch.qos.logback:logback-core:1.5.21=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -com.fasterxml.jackson.core:jackson-annotations:2.20=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -com.fasterxml.jackson.core:jackson-core:2.20.1=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -com.fasterxml.jackson.core:jackson-databind:2.20.1=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -com.fasterxml.jackson:jackson-bom:2.20.1=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -com.fasterxml:classmate:1.7.1=jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath -com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,jpaPlatformPerformanceTestAnnotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor -com.github.docker-java:docker-java-api:3.7.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -com.github.docker-java:docker-java-transport-zerodep:3.7.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -com.github.docker-java:docker-java-transport:3.7.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +ch.qos.logback:logback-classic:1.5.38=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +ch.qos.logback:logback-core:1.5.38=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +com.fasterxml.jackson.core:jackson-annotations:2.21=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +com.fasterxml.jackson.core:jackson-core:2.21.5=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +com.fasterxml.jackson.core:jackson-databind:2.21.5=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +com.fasterxml.jackson:jackson-bom:2.21.5=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +com.fasterxml:classmate:1.7.3=jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath +com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,jpaPlatformPerformanceTestAnnotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor +com.github.docker-java:docker-java-api:3.7.1=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +com.github.docker-java:docker-java-transport-zerodep:3.7.1=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +com.github.docker-java:docker-java-transport:3.7.1=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,jpaPlatformPerformanceTestAnnotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs -com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,postgresqlIntegrationTestCompileClasspath,testCompileClasspath,testkitCompileClasspath com.github.spotbugs:spotbugs:4.10.2=spotbugs com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,jpaPlatformPerformanceTestAnnotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,jpaPlatformPerformanceTestAnnotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor com.google.auto:auto-common:1.2.2=annotationProcessor,jpaPlatformPerformanceTestAnnotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor -com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,jpaPlatformPerformanceTestCompileClasspath,postgresqlIntegrationTestCompileClasspath,spotbugs,testCompileClasspath,testkitCompileClasspath +com.google.code.findbugs:jsr305:3.0.2=checkstyle,spotbugs com.google.code.gson:gson:2.13.2=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,spotbugs,testkitCompileClasspath,testkitRuntimeClasspath com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,jpaPlatformPerformanceTestAnnotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,testCompileClasspath @@ -39,7 +38,7 @@ com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,jpaPlatf com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,jpaPlatformPerformanceTestAnnotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor com.h2database:h2:2.4.240=jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins -com.jayway.jsonpath:json-path:2.9.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +com.jayway.jsonpath:json-path:2.10.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath com.mysema.commons:mysema-commons-lang:0.2.4=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath com.puppycrawl.tools:checkstyle:13.5.0=checkstyle com.querydsl:querydsl-core:5.1.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath @@ -57,24 +56,24 @@ commons-codec:commons-codec:1.19.0=jpaPlatformPerformanceTestCompileClasspath,jp commons-collections:commons-collections:3.2.2=checkstyle commons-io:commons-io:2.20.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath commons-io:commons-io:2.21.0=spotbugs -commons-logging:commons-logging:1.3.5=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +commons-logging:commons-logging:1.3.6=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath eu.rekawek.toxiproxy:toxiproxy-java:2.1.11=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath info.picocli:picocli:4.7.7=checkstyle io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,jpaPlatformPerformanceTestAnnotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,jpaPlatformPerformanceTestAnnotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor -io.micrometer:micrometer-commons:1.16.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -io.micrometer:micrometer-core:1.16.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -io.micrometer:micrometer-observation:1.16.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -io.projectreactor:reactor-core:3.8.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -io.smallrye:jandex:3.3.1=jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestRuntimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath +io.micrometer:micrometer-commons:1.16.7=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +io.micrometer:micrometer-core:1.16.7=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +io.micrometer:micrometer-observation:1.16.7=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +io.projectreactor:reactor-core:3.8.7=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +io.smallrye:jandex:3.3.2=jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestRuntimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath jakarta.activation:jakarta.activation-api:2.1.4=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath jakarta.inject:jakarta.inject-api:2.0.1=jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath jakarta.persistence:jakarta.persistence-api:3.2.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath jakarta.transaction:jakarta.transaction-api:2.0.1=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +jakarta.xml.bind:jakarta.xml.bind-api:4.0.5=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath javax.inject:javax.inject:1=annotationProcessor,jpaPlatformPerformanceTestAnnotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor -jaxen:jaxen:2.0.0=spotbugs +jaxen:jaxen:2.0.6=spotbugs net.bytebuddy:byte-buddy-agent:1.17.8=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath net.bytebuddy:byte-buddy:1.17.8=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath net.java.dev.jna:jna:5.18.1=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath @@ -89,22 +88,22 @@ org.apache.commons:commons-text:1.15.0=spotbugs org.apache.commons:commons-text:1.3=checkstyle org.apache.httpcomponents:httpclient:4.5.13=checkstyle org.apache.httpcomponents:httpcore:4.4.16=checkstyle -org.apache.logging.log4j:log4j-api:2.25.2=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.apache.logging.log4j:log4j-core:2.25.2=spotbugs -org.apache.logging.log4j:log4j-to-slf4j:2.25.2=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.apache.logging.log4j:log4j-api:2.25.5=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.apache.logging.log4j:log4j-core:2.25.5=spotbugs +org.apache.logging.log4j:log4j-to-slf4j:2.25.5=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath org.apache.maven.doxia:doxia-core:1.12.0=checkstyle org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle -org.apache.tomcat.embed:tomcat-embed-core:11.0.14=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.apache.tomcat.embed:tomcat-embed-el:11.0.14=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-core:11.0.24=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-el:11.0.24=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-websocket:11.0.24=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath org.apache.xbean:xbean-reflect:3.7=checkstyle org.apiguardian:apiguardian-api:1.1.2=jpaPlatformPerformanceTestCompileClasspath,postgresqlIntegrationTestCompileClasspath,testCompileClasspath,testkitCompileClasspath -org.aspectj:aspectjweaver:1.9.25=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.assertj:assertj-core:3.27.6=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.aspectj:aspectjweaver:1.9.25.1=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.assertj:assertj-core:3.27.7=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath org.awaitility:awaitility:4.3.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.checkerframework:checker-qual:3.49.5=jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath +org.checkerframework:checker-qual:3.55.1=jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle @@ -113,26 +112,26 @@ org.dom4j:dom4j:2.2.0=spotbugs org.eclipse.angus:angus-activation:2.0.3=jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath org.flywaydb:flyway-core:11.14.1=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath org.flywaydb:flyway-database-postgresql:11.14.1=jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath -org.glassfish.jaxb:jaxb-core:4.0.6=jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath -org.glassfish.jaxb:jaxb-runtime:4.0.6=jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath -org.glassfish.jaxb:txw2:4.0.6=jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath +org.glassfish.jaxb:jaxb-core:4.0.9=jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath +org.glassfish.jaxb:jaxb-runtime:4.0.9=jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath +org.glassfish.jaxb:txw2:4.0.9=jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath org.hamcrest:hamcrest:3.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath org.hdrhistogram:HdrHistogram:2.2.2=jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath org.hibernate.models:hibernate-models:1.0.1=jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath -org.hibernate.orm:hibernate-core:7.1.8.Final=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.hibernate.orm:hibernate-envers:7.1.8.Final=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.hibernate.orm:hibernate-core:7.2.24.Final=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.hibernate.orm:hibernate-envers:7.2.24.Final=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath org.javassist:javassist:3.28.0-GA=checkstyle -org.jboss.logging:jboss-logging:3.6.1.Final=jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath +org.jboss.logging:jboss-logging:3.6.3.Final=jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath org.jetbrains:annotations:17.0.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,jpaPlatformPerformanceTestAnnotationProcessor,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestAnnotationProcessor,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath,testkitAnnotationProcessor,testkitCompileClasspath,testkitRuntimeClasspath -org.junit.jupiter:junit-jupiter-api:6.0.1=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.junit.jupiter:junit-jupiter-engine:6.0.1=jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestRuntimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath -org.junit.jupiter:junit-jupiter-params:6.0.1=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.junit.jupiter:junit-jupiter:6.0.1=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.junit.platform:junit-platform-commons:6.0.1=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.junit.platform:junit-platform-engine:6.0.1=jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestRuntimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath -org.junit.platform:junit-platform-launcher:6.0.1=jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestRuntimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath -org.junit:junit-bom:6.0.1=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.jspecify:jspecify:1.0.1=annotationProcessor,checkstyle,compileClasspath,jpaPlatformPerformanceTestAnnotationProcessor,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestAnnotationProcessor,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath,testkitAnnotationProcessor,testkitCompileClasspath,testkitRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:6.0.3=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.3=jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestRuntimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.3=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.3=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.3=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.3=jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestRuntimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.3=jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestRuntimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath +org.junit:junit-bom:6.0.3=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath org.junit:junit-bom:6.1.0=spotbugs org.latencyutils:LatencyUtils:2.0.3=jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath org.mockito:mockito-core:5.20.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,mockitoAgent,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath @@ -150,77 +149,76 @@ org.ow2.asm:asm-util:9.10.1=spotbugs org.ow2.asm:asm:9.10.1=spotbugs org.ow2.asm:asm:9.7.1=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath org.pcollections:pcollections:4.0.1=annotationProcessor,jpaPlatformPerformanceTestAnnotationProcessor,postgresqlIntegrationTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor -org.postgresql:postgresql:42.7.8=jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath +org.postgresql:postgresql:42.7.13=jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath org.reactivestreams:reactive-streams:1.0.4=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath org.reflections:reflections:0.10.2=checkstyle org.rnorth.duct-tape:duct-tape:1.0.8=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath org.skyscreamer:jsonassert:1.5.3=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.slf4j:slf4j-api:2.0.17=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j -org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.boot:spring-boot-configuration-processor:4.0.0=annotationProcessor -org.springframework.boot:spring-boot-data-commons:4.0.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.boot:spring-boot-data-jpa:4.0.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.boot:spring-boot-flyway:4.0.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.boot:spring-boot-hibernate:4.0.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.boot:spring-boot-http-client:4.0.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.boot:spring-boot-http-converter:4.0.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.boot:spring-boot-jackson:4.0.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.boot:spring-boot-jdbc:4.0.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.boot:spring-boot-jpa:4.0.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.boot:spring-boot-persistence:4.0.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.boot:spring-boot-restclient:4.0.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.boot:spring-boot-resttestclient:4.0.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.boot:spring-boot-servlet:4.0.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.boot:spring-boot-sql:4.0.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.boot:spring-boot-starter-data-jpa:4.0.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.boot:spring-boot-starter-flyway:4.0.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.boot:spring-boot-starter-jackson:4.0.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.boot:spring-boot-starter-jdbc:4.0.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.boot:spring-boot-starter-test:4.0.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.boot:spring-boot-starter-tomcat:4.0.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.boot:spring-boot-starter-webmvc:4.0.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.boot:spring-boot-starter:4.0.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.boot:spring-boot-test:4.0.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.boot:spring-boot-tomcat:4.0.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.boot:spring-boot-transaction:4.0.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.boot:spring-boot-web-server:4.0.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.boot:spring-boot-webmvc-test:4.0.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.boot:spring-boot-webmvc:4.0.0=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.boot:spring-boot:4.0.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.data:spring-data-commons:4.0.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.data:spring-data-jpa:4.0.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.integration:spring-integration-core:7.0.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.integration:spring-integration-jdbc:7.0.0=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework:spring-aop:7.0.1=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework:spring-aspects:7.0.1=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework:spring-beans:7.0.1=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework:spring-context:7.0.1=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework:spring-core:7.0.1=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework:spring-expression:7.0.1=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework:spring-jdbc:7.0.1=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework:spring-messaging:7.0.1=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework:spring-orm:7.0.1=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework:spring-test:7.0.1=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework:spring-tx:7.0.1=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework:spring-web:7.0.1=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework:spring-webmvc:7.0.1=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.testcontainers:testcontainers-database-commons:2.0.2=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.testcontainers:testcontainers-jdbc:2.0.2=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.testcontainers:testcontainers-junit-jupiter:2.0.2=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.testcontainers:testcontainers-postgresql:2.0.2=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.testcontainers:testcontainers-toxiproxy:2.0.2=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.testcontainers:testcontainers:2.0.2=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.slf4j:jul-to-slf4j:2.0.18=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.slf4j:slf4j-api:2.0.18=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.18=checkstyle +org.springframework.boot:spring-boot-autoconfigure:4.0.8=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-configuration-processor:4.0.8=annotationProcessor +org.springframework.boot:spring-boot-data-commons:4.0.8=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-data-jpa:4.0.8=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-flyway:4.0.8=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-hibernate:4.0.8=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-http-converter:4.0.8=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-jackson:4.0.8=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-jdbc:4.0.8=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-jpa:4.0.8=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-persistence:4.0.8=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-resttestclient:4.0.8=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-servlet:4.0.8=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-sql:4.0.8=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-starter-data-jpa:4.0.8=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-starter-flyway:4.0.8=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-starter-jackson-test:4.0.8=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-starter-jackson:4.0.8=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-starter-jdbc:4.0.8=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-starter-logging:4.0.8=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-starter-test:4.0.8=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.8=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat:4.0.8=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-starter-webmvc-test:4.0.8=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-starter-webmvc:4.0.8=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-starter:4.0.8=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-test-autoconfigure:4.0.8=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-test:4.0.8=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-tomcat:4.0.8=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-transaction:4.0.8=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-web-server:4.0.8=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-webmvc-test:4.0.8=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-webmvc:4.0.8=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot:4.0.8=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.data:spring-data-commons:4.0.7=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.data:spring-data-jpa:4.0.7=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.integration:spring-integration-core:7.0.6=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.integration:spring-integration-jdbc:7.0.6=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework:spring-aop:7.0.9=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework:spring-aspects:7.0.9=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework:spring-beans:7.0.9=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework:spring-context:7.0.9=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework:spring-core:7.0.9=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework:spring-expression:7.0.9=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework:spring-jdbc:7.0.9=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework:spring-messaging:7.0.9=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework:spring-orm:7.0.9=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework:spring-test:7.0.9=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework:spring-tx:7.0.9=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework:spring-web:7.0.9=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework:spring-webmvc:7.0.9=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.testcontainers:testcontainers-database-commons:2.0.5=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.testcontainers:testcontainers-jdbc:2.0.5=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.testcontainers:testcontainers-junit-jupiter:2.0.5=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.testcontainers:testcontainers-postgresql:2.0.5=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.testcontainers:testcontainers-toxiproxy:2.0.5=postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.testcontainers:testcontainers:2.0.5=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs org.xmlunit:xmlunit-core:2.10.4=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath org.yaml:snakeyaml:2.5=compileClasspath,jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -tools.jackson.core:jackson-core:3.0.2=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -tools.jackson.core:jackson-databind:3.0.2=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -tools.jackson:jackson-bom:3.0.2=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +tools.jackson.core:jackson-core:3.1.5=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +tools.jackson.core:jackson-databind:3.1.5=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +tools.jackson:jackson-bom:3.1.5=jpaPlatformPerformanceTestCompileClasspath,jpaPlatformPerformanceTestRuntimeClasspath,postgresqlIntegrationTestCompileClasspath,postgresqlIntegrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath empty= diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/config/PersistenceJpaConfig.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/config/PersistenceJpaConfig.java index cd19fe68..8ad3eb93 100644 --- a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/config/PersistenceJpaConfig.java +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/config/PersistenceJpaConfig.java @@ -49,6 +49,8 @@ import org.springframework.data.jpa.repository.config.EnableJpaRepositories; "dev.caskeleton.adapter.outbound.persistence.lock", "dev.caskeleton.adapter.outbound.persistence.migration", "dev.caskeleton.adapter.outbound.persistence.observation", + "dev.caskeleton.adapter.outbound.persistence.liveevent", + "dev.caskeleton.adapter.outbound.persistence.operation", "dev.caskeleton.adapter.outbound.persistence.outbox", "dev.caskeleton.adapter.outbound.persistence.postgresql", "dev.caskeleton.adapter.outbound.persistence.querydsl", @@ -70,6 +72,8 @@ import org.springframework.data.jpa.repository.config.EnableJpaRepositories; "dev.caskeleton.adapter.outbound.persistence.lock", "dev.caskeleton.adapter.outbound.persistence.migration", "dev.caskeleton.adapter.outbound.persistence.observation", + "dev.caskeleton.adapter.outbound.persistence.liveevent", + "dev.caskeleton.adapter.outbound.persistence.operation", "dev.caskeleton.adapter.outbound.persistence.outbox", "dev.caskeleton.adapter.outbound.persistence.postgresql", "dev.caskeleton.adapter.outbound.persistence.querydsl", diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/liveevent/JpaLiveEventReplayAdapter.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/liveevent/JpaLiveEventReplayAdapter.java new file mode 100644 index 00000000..4ea0f80e --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/liveevent/JpaLiveEventReplayAdapter.java @@ -0,0 +1,135 @@ +package dev.caskeleton.adapter.outbound.persistence.liveevent; + +import dev.caskeleton.adapter.outbound.persistence.liveevent.entity.LiveEventEntity; +import dev.caskeleton.application.realtime.LiveEventReplayPort; +import dev.caskeleton.application.realtime.ReplayCursorUnavailableException; +import dev.caskeleton.application.realtime.ReplayWindow; +import dev.caskeleton.application.realtime.ReplayedEvent; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import org.springframework.data.domain.Limit; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +/** + * Retained live-event history, on PostgreSQL. + * + *

Durable history lives here rather than in a transport adapter for one reason: a second copy + * would have its own retention, its own eviction and its own opinion about ordering, and the two + * would diverge silently because both would look plausible. The web and websocket modules reach it + * through {@link LiveEventReplayPort} and hold nothing. + * + *

Positions are assigned per stream and are never reused, including after a sweep. Reusing a + * swept position would give two different events the same address, and a client holding the older + * cursor would receive the newer event as though it were the one it asked to continue after — with + * nothing in the data to say so. + * + *

The append is transactional and reads the high-water mark inside the same transaction. Two + * concurrent appends to one stream would otherwise both read the same maximum and both write it, + * which the primary key turns into a constraint violation rather than a duplicate — and a + * constraint violation is the right outcome, because the alternative is two events sharing a + * position. + */ +@Repository +public class JpaLiveEventReplayAdapter implements LiveEventReplayPort { + + /** The most one replay call will load, whatever the caller asks for. */ + public static final int MAX_REPLAY_LIMIT = 500; + + private final LiveEventJpaRepository repository; + private final Clock clock; + private final Duration retention; + + /** + * An adapter over the event log. + * + * @param repository the Spring Data access + * @param clock the clock retention is measured against + * @param retention how long an appended event stays replayable + */ + public JpaLiveEventReplayAdapter( + LiveEventJpaRepository repository, Clock clock, Duration retention) { + this.repository = Objects.requireNonNull(repository, "repository"); + this.clock = Objects.requireNonNull(clock, "clock"); + this.retention = Objects.requireNonNull(retention, "retention"); + if (retention.isNegative() || retention.isZero()) { + throw new IllegalArgumentException( + "a zero retention makes every cursor unresumable the moment it is issued"); + } + } + + /** + * Appends an event and returns the position it was given. + * + *

Not on the port. A transport adapter reads history and never writes it; exposing an append + * on the port would let one write its own version of events into the store the others read. + * + * @param streamId which stream + * @param payload the encoded event + * @return the assigned position + */ + @Transactional + public long append(String streamId, String payload) { + Objects.requireNonNull(streamId, "streamId"); + Objects.requireNonNull(payload, "payload"); + Instant now = clock.instant(); + Long highest = repository.highestEverAssigned(streamId); + long position = highest == null ? 1L : highest + 1L; + repository.save(new LiveEventEntity(streamId, position, payload, now, now.plus(retention))); + return position; + } + + @Override + @Transactional(readOnly = true) + public ReplayWindow window(String streamId) { + Objects.requireNonNull(streamId, "streamId"); + Instant now = clock.instant(); + Long earliest = repository.earliestRetained(streamId, now); + Long latest = repository.latestRetained(streamId, now); + if (earliest == null || latest == null) { + return ReplayWindow.empty(streamId); + } + return new ReplayWindow(streamId, Optional.of(earliest), Optional.of(latest)); + } + + @Override + @Transactional(readOnly = true) + public List replayAfter(String streamId, long afterPosition, int limit) { + Objects.requireNonNull(streamId, "streamId"); + if (afterPosition < 0) { + throw new IllegalArgumentException("a cursor cannot be negative"); + } + if (limit < 1) { + throw new IllegalArgumentException("a replay of nothing is not a replay"); + } + Instant now = clock.instant(); + // Checked before reading rather than inferred from an empty result. An empty result also means + // "nothing new", which is the opposite situation and needs the opposite response — the client + // stays subscribed rather than resnapshotting. + if (!window(streamId).canResumeAfter(afterPosition)) { + throw new ReplayCursorUnavailableException(streamId, afterPosition); + } + return repository + .replayAfter(streamId, afterPosition, now, Limit.of(Math.min(limit, MAX_REPLAY_LIMIT))) + .stream() + .map( + row -> + new ReplayedEvent( + row.getStreamId(), row.getPosition(), row.getPayload(), row.getOccurredAt())) + .toList(); + } + + /** + * Removes events past their retention. + * + * @return how many went + */ + @Transactional + public int sweepExpired() { + return repository.sweepExpired(clock.instant()); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/liveevent/LiveEventJpaRepository.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/liveevent/LiveEventJpaRepository.java new file mode 100644 index 00000000..72fd84b9 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/liveevent/LiveEventJpaRepository.java @@ -0,0 +1,87 @@ +package dev.caskeleton.adapter.outbound.persistence.liveevent; + +import dev.caskeleton.adapter.outbound.persistence.liveevent.entity.LiveEventEntity; +import java.time.Instant; +import java.util.List; +import org.springframework.data.domain.Limit; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +/** + * Spring Data access for {@code live_event_log}. + * + *

Every read is bounded and every read filters on retention. The bound is because a replay is + * driven by a client-supplied cursor, so an unbounded query is a client-controlled amount of + * memory; the retention filter is because an expired row that has not yet been swept is not history + * the client may have — serving it would make the window this store reports and the window it + * actually honours two different things. + */ +public interface LiveEventJpaRepository + extends JpaRepository { + + /** + * Events after a position, oldest first. + * + * @param streamId which stream + * @param afterPosition the last position the client already has + * @param now rows retained past this instant only + * @param limit the page size + */ + @Query( + """ + select e from LiveEventEntity e + where e.id.streamId = :streamId + and e.id.position > :afterPosition + and e.retainedUntil > :now + order by e.id.position asc + """) + List replayAfter( + @Param("streamId") String streamId, + @Param("afterPosition") long afterPosition, + @Param("now") Instant now, + Limit limit); + + /** + * The oldest position still retained. + * + *

Separate from the newest rather than one query returning both, because a stream that holds + * nothing must return no row at all — an aggregate query would return one row of nulls, and the + * caller would have to tell that apart from a stream whose bounds happen to be null. + */ + @Query( + """ + select min(e.id.position) from LiveEventEntity e + where e.id.streamId = :streamId and e.retainedUntil > :now + """) + Long earliestRetained(@Param("streamId") String streamId, @Param("now") Instant now); + + /** The newest position still retained. */ + @Query( + """ + select max(e.id.position) from LiveEventEntity e + where e.id.streamId = :streamId and e.retainedUntil > :now + """) + Long latestRetained(@Param("streamId") String streamId, @Param("now") Instant now); + + /** + * The highest position ever assigned to a stream, retained or not. + * + *

Ignores retention on purpose. Positions must never be reused: assigning a swept position + * again would give two different events the same address, and a client holding the older cursor + * would silently receive the newer event as though it were the one it asked to continue after. + */ + @Query("select max(e.id.position) from LiveEventEntity e where e.id.streamId = :streamId") + Long highestEverAssigned(@Param("streamId") String streamId); + + /** + * Removes expired rows. + * + * @param now rows retained no later than this are removed + * @return how many went + */ + @Modifying + @Query("delete from LiveEventEntity e where e.retainedUntil <= :now") + int sweepExpired(@Param("now") Instant now); +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/liveevent/entity/LiveEventEntity.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/liveevent/entity/LiveEventEntity.java new file mode 100644 index 00000000..9280b1b6 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/liveevent/entity/LiveEventEntity.java @@ -0,0 +1,134 @@ +package dev.caskeleton.adapter.outbound.persistence.liveevent.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import jakarta.persistence.EmbeddedId; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import java.io.Serializable; +import java.time.Instant; +import java.util.Objects; + +/** + * JPA row for {@code live_event_log}; schema owned by Flyway ({@code V12__live_event_log.sql}). + * + *

The key is the stream and the position together, and the position is dense per stream rather + * than globally. A single global sequence would be simpler and would leak: a client resuming a feed + * it is entitled to would see gaps whose size reveals how much traffic every other stream carried, + * which for a per-tenant feed is a usable side channel. + * + *

{@code retainedUntil} is stored rather than derived from a policy elsewhere. A reader has to + * distinguish "you are up to date" from "your cursor is older than what we kept", and the second + * answer needs the row that would have answered it to be known-gone rather than merely absent. + */ +@Entity +@Table(name = "live_event_log") +public class LiveEventEntity { + + @EmbeddedId private LiveEventId id; + + // No columnDefinition: the physical type belongs to the Flyway migration, and pinning it here + // would make this entity carry a PostgreSQL type into a vendor-neutral package. + @Column(name = "payload", nullable = false, length = 1_048_576) + private String payload; + + @Column(name = "occurred_at", nullable = false) + private Instant occurredAt; + + @Column(name = "retained_until", nullable = false) + private Instant retainedUntil; + + protected LiveEventEntity() { + // JPA. + } + + /** + * Creates a row. + * + * @param streamId which stream + * @param position where in it, counting from 1 + * @param payload the encoded event + * @param occurredAt when it happened + * @param retainedUntil when it stops being available for replay + */ + public LiveEventEntity( + String streamId, long position, String payload, Instant occurredAt, Instant retainedUntil) { + this.id = new LiveEventId(streamId, position); + this.payload = payload; + this.occurredAt = occurredAt; + this.retainedUntil = retainedUntil; + } + + /** Which stream. */ + public String getStreamId() { + return id.getStreamId(); + } + + /** Where in it. */ + public long getPosition() { + return id.getPosition(); + } + + /** The encoded event. */ + public String getPayload() { + return payload; + } + + /** When it happened. */ + public Instant getOccurredAt() { + return occurredAt; + } + + /** When it stops being replayable. */ + public Instant getRetainedUntil() { + return retainedUntil; + } + + /** The composite key. */ + @Embeddable + public static class LiveEventId implements Serializable { + + private static final long serialVersionUID = 1L; + + @Column(name = "stream_id", nullable = false, length = 128) + private String streamId; + + @Column(name = "position", nullable = false) + private long position; + + protected LiveEventId() { + // JPA. + } + + LiveEventId(String streamId, long position) { + this.streamId = streamId; + this.position = position; + } + + /** Which stream. */ + public String getStreamId() { + return streamId; + } + + /** Where in it. */ + public long getPosition() { + return position; + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (!(other instanceof LiveEventId that)) { + return false; + } + return position == that.position && Objects.equals(streamId, that.streamId); + } + + @Override + public int hashCode() { + return Objects.hash(streamId, position); + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/operation/DurableOperationJpaRepository.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/operation/DurableOperationJpaRepository.java new file mode 100644 index 00000000..573cbd52 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/operation/DurableOperationJpaRepository.java @@ -0,0 +1,234 @@ +package dev.caskeleton.adapter.outbound.persistence.operation; + +import dev.caskeleton.adapter.outbound.persistence.operation.entity.DurableOperationEntity; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +/** + * Spring Data access for {@code durable_operation}. + * + *

The claim is a native query with {@code FOR UPDATE SKIP LOCKED}. Nothing portable expresses + * it, and the alternatives are worse in ways that only appear under load: an optimistic + * claim-then-check has every worker fetch the same head row and all but one retry, and a plain + * {@code FOR UPDATE} makes them queue behind each other so the pool serialises. + * + *

Every state-changing statement carries the owning worker in its {@code WHERE} clause and + * returns the affected count. A worker whose lease lapsed while it was still working must not be + * able to record a result over the worker that took over; the update simply matches nothing, and + * the caller learns it lost the lease from the count. + */ +public interface DurableOperationJpaRepository + extends JpaRepository { + + /** Reads an operation a specific caller is entitled to see. */ + Optional findByOperationIdAndPrincipal( + String operationId, String principal); + + /** Reads the row an identical submission already created. */ + Optional findByTenantAndPrincipalAndOperationNameAndRequestHash( + String tenant, String principal, String operationName, String requestHash); + + /** + * Claims the oldest runnable operation for one worker. + * + *

Runnable is PENDING, or RUNNING whose lease has lapsed. The second half is the crash + * recovery path: without it an operation claimed by a worker that then died is never picked up + * again, and the queue stops draining while every row still reads RUNNING. + */ + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query( + value = + """ + UPDATE durable_operation + SET state = 'RUNNING', + lease_owner = :workerId, + lease_acquired_at = :now, + lease_expires_at = :leaseExpiresAt, + started_at = COALESCE(started_at, :now), + row_version = row_version + 1 + WHERE operation_id = ( + SELECT operation_id + FROM durable_operation + WHERE (state = 'PENDING' + OR (state = 'RUNNING' AND lease_expires_at <= :now)) + AND expires_at > :now + ORDER BY submitted_at + FOR UPDATE SKIP LOCKED + LIMIT 1) + RETURNING operation_id + """, + nativeQuery = true) + List claimNext( + @Param("workerId") String workerId, + @Param("leaseExpiresAt") Instant leaseExpiresAt, + @Param("now") Instant now); + + /** Extends a lease the worker still holds. */ + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query( + value = + """ + UPDATE durable_operation + SET lease_expires_at = :leaseExpiresAt, + row_version = row_version + 1 + WHERE operation_id = :operationId + AND state = 'RUNNING' + AND lease_owner = :workerId + AND lease_expires_at > :now + """, + nativeQuery = true) + int heartbeat( + @Param("operationId") String operationId, + @Param("workerId") String workerId, + @Param("leaseExpiresAt") Instant leaseExpiresAt, + @Param("now") Instant now); + + /** Publishes progress for an operation the worker still holds. */ + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query( + value = + """ + UPDATE durable_operation + SET progress_completed = :completed, + progress_total = :total, + progress_phase = :phase, + row_version = row_version + 1 + WHERE operation_id = :operationId + AND state = 'RUNNING' + AND lease_owner = :workerId + """, + nativeQuery = true) + int reportProgress( + @Param("operationId") String operationId, + @Param("workerId") String workerId, + @Param("completed") long completed, + @Param("total") Long total, + @Param("phase") String phase); + + /** Records success and releases the lease. */ + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query( + value = + """ + UPDATE durable_operation + SET state = 'SUCCEEDED', + result_reference = :resultReference, + completed_at = :completedAt, + lease_owner = NULL, + lease_acquired_at = NULL, + lease_expires_at = NULL, + row_version = row_version + 1 + WHERE operation_id = :operationId + AND state = 'RUNNING' + AND lease_owner = :workerId + """, + nativeQuery = true) + int succeed( + @Param("operationId") String operationId, + @Param("workerId") String workerId, + @Param("resultReference") String resultReference, + @Param("completedAt") Instant completedAt); + + /** Records failure and releases the lease. */ + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query( + value = + """ + UPDATE durable_operation + SET state = 'FAILED', + failure_code = :code, + failure_detail = :detail, + failure_retryable = :retryable, + completed_at = :completedAt, + lease_owner = NULL, + lease_acquired_at = NULL, + lease_expires_at = NULL, + row_version = row_version + 1 + WHERE operation_id = :operationId + AND state = 'RUNNING' + AND lease_owner = :workerId + """, + nativeQuery = true) + int fail( + @Param("operationId") String operationId, + @Param("workerId") String workerId, + @Param("code") String code, + @Param("detail") String detail, + @Param("retryable") boolean retryable, + @Param("completedAt") Instant completedAt); + + /** + * Cancels an operation that has not finished. + * + *

The state predicate is what makes this idempotent and non-destructive: a second cancel + * matches nothing, and a cancel that arrives after the work succeeded cannot replace a real + * answer with a claim that it never happened. + */ + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query( + value = + """ + UPDATE durable_operation + SET state = 'CANCELED', + completed_at = :canceledAt, + lease_owner = NULL, + lease_acquired_at = NULL, + lease_expires_at = NULL, + row_version = row_version + 1 + WHERE operation_id = :operationId + AND principal = :principal + AND state IN ('PENDING', 'RUNNING') + """, + nativeQuery = true) + int cancel( + @Param("operationId") String operationId, + @Param("principal") String principal, + @Param("canceledAt") Instant canceledAt); + + /** Returns operations whose lease lapsed to PENDING. */ + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query( + value = + """ + UPDATE durable_operation + SET state = 'PENDING', + lease_owner = NULL, + lease_acquired_at = NULL, + lease_expires_at = NULL, + row_version = row_version + 1 + WHERE state = 'RUNNING' + AND lease_expires_at <= :now + RETURNING operation_id + """, + nativeQuery = true) + List reclaimExpiredLeases(@Param("now") Instant now); + + /** + * Marks aged-out records EXPIRED. + * + *

SUCCEEDED and FAILED rows are left alone: their answer is still the truth about what + * happened, and overwriting it with EXPIRED would destroy the only record of it. + */ + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query( + value = + """ + UPDATE durable_operation + SET state = 'EXPIRED', + completed_at = :now, + lease_owner = NULL, + lease_acquired_at = NULL, + lease_expires_at = NULL, + row_version = row_version + 1 + WHERE state IN ('PENDING', 'RUNNING') + AND expires_at <= :now + RETURNING operation_id + """, + nativeQuery = true) + List expireStaleOperations(@Param("now") Instant now); +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/operation/DurableOperationStoreAdapter.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/operation/DurableOperationStoreAdapter.java new file mode 100644 index 00000000..97d1dc0a --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/operation/DurableOperationStoreAdapter.java @@ -0,0 +1,249 @@ +package dev.caskeleton.adapter.outbound.persistence.operation; + +import dev.caskeleton.adapter.outbound.persistence.operation.entity.DurableOperationEntity; +import dev.caskeleton.application.idempotency.RequestFingerprint; +import dev.caskeleton.application.operation.DurableOperation; +import dev.caskeleton.application.operation.DurableOperationId; +import dev.caskeleton.application.operation.DurableOperationState; +import dev.caskeleton.application.operation.DurableOperationStorePort; +import dev.caskeleton.application.operation.DurableOperationSubmission; +import dev.caskeleton.application.operation.OperationFailure; +import dev.caskeleton.application.operation.OperationLease; +import dev.caskeleton.application.operation.OperationProgressSnapshot; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +/** + * The PostgreSQL-backed {@link DurableOperationStorePort}. + * + *

Every write is one statement whose {@code WHERE} clause states the precondition, and the + * affected count is the answer. The alternative — read the row, decide in Java, write it back — has + * a window between the read and the write in which another worker can take the lease, and the + * lost-update it produces is exactly the case this store exists to prevent. + */ +@Repository +public class DurableOperationStoreAdapter implements DurableOperationStorePort { + + private final DurableOperationJpaRepository repository; + + /** + * An adapter over the operation table. + * + * @param repository the Spring Data access + */ + public DurableOperationStoreAdapter(DurableOperationJpaRepository repository) { + this.repository = repository; + } + + @Override + @Transactional + public DurableOperation submit(DurableOperationSubmission submission) { + String tenant = submission.tenantId() == null ? "" : submission.tenantId(); + try { + DurableOperationEntity saved = + repository.saveAndFlush( + new DurableOperationEntity( + submission.operationId().value(), + tenant, + submission.principal(), + submission.operationName(), + submission.fingerprint().hex(), + submission.payload(), + submission.submittedAt(), + submission.expiresAt())); + return toDomain(saved); + } catch (DataIntegrityViolationException duplicate) { + // The unique constraint is the arbiter, not a prior read: two identical submissions can + // reach this line at the same instant, and only one of them can win. The loser reads the + // winner's row rather than reporting a conflict, which is the whole point of resubmission + // being safe. + return repository + .findByTenantAndPrincipalAndOperationNameAndRequestHash( + tenant, + submission.principal(), + submission.operationName(), + submission.fingerprint().hex()) + .map(DurableOperationStoreAdapter::toDomain) + .orElseThrow( + () -> + new IllegalStateException( + "the unique constraint rejected a submission but no matching operation" + + " exists; the constraint and this lookup disagree on scope", + duplicate)); + } + } + + @Override + @Transactional(readOnly = true) + public Optional find( + DurableOperationId operationId, String principal, Instant now) { + return repository + .findByOperationIdAndPrincipal(operationId.value(), principal) + .map(DurableOperationStoreAdapter::toDomain) + .map(operation -> expireIfStale(operation, now)); + } + + @Override + @Transactional + public Optional claimNext( + String workerId, Duration leaseDuration, Instant now) { + List claimed = repository.claimNext(workerId, now.plus(leaseDuration), now); + if (claimed.isEmpty()) { + return Optional.empty(); + } + return repository + .findById(claimed.get(0)) + .map( + entity -> + new DurableOperationSubmission( + new DurableOperationId(entity.getOperationId()), + entity.getOperationName(), + entity.getPrincipal(), + entity.getTenant().isEmpty() ? null : entity.getTenant(), + new RequestFingerprint(entity.getRequestHash()), + entity.getPayload(), + entity.getSubmittedAt(), + Duration.between(entity.getSubmittedAt(), entity.getExpiresAt()))); + } + + @Override + @Transactional + public boolean heartbeat( + DurableOperationId operationId, String workerId, Duration leaseDuration, Instant now) { + return repository.heartbeat(operationId.value(), workerId, now.plus(leaseDuration), now) == 1; + } + + @Override + @Transactional + public boolean reportProgress( + DurableOperationId operationId, String workerId, OperationProgressSnapshot progress) { + return repository.reportProgress( + operationId.value(), + workerId, + progress.completedUnits(), + progress.totalUnits().orElse(null), + progress.phase().orElse(null)) + == 1; + } + + @Override + @Transactional + public boolean succeed( + DurableOperationId operationId, + String workerId, + String resultReference, + Instant completedAt) { + return repository.succeed(operationId.value(), workerId, resultReference, completedAt) == 1; + } + + @Override + @Transactional + public boolean fail( + DurableOperationId operationId, + String workerId, + OperationFailure failure, + Instant completedAt) { + return repository.fail( + operationId.value(), + workerId, + failure.code(), + failure.detail(), + failure.retryable(), + completedAt) + == 1; + } + + @Override + @Transactional + public boolean cancel(DurableOperationId operationId, String principal, Instant canceledAt) { + return repository.cancel(operationId.value(), principal, canceledAt) == 1; + } + + @Override + @Transactional + public List reclaimExpiredLeases(Instant now) { + return repository.reclaimExpiredLeases(now).stream().map(DurableOperationId::new).toList(); + } + + @Override + @Transactional + public List expireStaleOperations(Instant now) { + return repository.expireStaleOperations(now).stream().map(DurableOperationId::new).toList(); + } + + private static DurableOperation expireIfStale(DurableOperation operation, Instant now) { + // Reported as EXPIRED on read even before the sweeper has run. A poller asking after the TTL + // must not be shown RUNNING for a record nothing will ever advance again. + if (operation.finished() || now.isBefore(operation.expiresAt())) { + return operation; + } + return new DurableOperation( + operation.operationId(), + operation.operationName(), + operation.principal(), + operation.tenantId(), + DurableOperationState.EXPIRED, + operation.submittedAt(), + operation.startedAt(), + Optional.of(operation.expiresAt()), + operation.progress(), + operation.resultReference(), + Optional.empty(), + Optional.empty(), + operation.expiresAt()); + } + + private static DurableOperation toDomain(DurableOperationEntity entity) { + DurableOperationState state = DurableOperationState.valueOf(entity.getState()); + return new DurableOperation( + new DurableOperationId(entity.getOperationId()), + entity.getOperationName(), + entity.getPrincipal(), + entity.getTenant().isEmpty() ? null : entity.getTenant(), + state, + entity.getSubmittedAt(), + Optional.ofNullable(entity.getStartedAt()), + Optional.ofNullable(entity.getCompletedAt()), + progressOf(entity), + Optional.ofNullable(entity.getResultReference()), + failureOf(entity), + leaseOf(entity), + entity.getExpiresAt()); + } + + private static Optional progressOf(DurableOperationEntity entity) { + if (entity.getProgressCompleted() == null) { + return Optional.empty(); + } + return Optional.of( + new OperationProgressSnapshot( + entity.getProgressCompleted(), + Optional.ofNullable(entity.getProgressTotal()), + Optional.ofNullable(entity.getProgressPhase()))); + } + + private static Optional failureOf(DurableOperationEntity entity) { + if (entity.getFailureCode() == null) { + return Optional.empty(); + } + return Optional.of( + new OperationFailure( + entity.getFailureCode(), + entity.getFailureDetail() == null ? "" : entity.getFailureDetail(), + Boolean.TRUE.equals(entity.getFailureRetryable()))); + } + + private static Optional leaseOf(DurableOperationEntity entity) { + if (entity.getLeaseOwner() == null || entity.getLeaseExpiresAt() == null) { + return Optional.empty(); + } + return Optional.of( + new OperationLease( + entity.getLeaseOwner(), entity.getLeaseAcquiredAt(), entity.getLeaseExpiresAt())); + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/operation/entity/DurableOperationEntity.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/operation/entity/DurableOperationEntity.java new file mode 100644 index 00000000..7d04cfbc --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/operation/entity/DurableOperationEntity.java @@ -0,0 +1,239 @@ +package dev.caskeleton.adapter.outbound.persistence.operation.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import jakarta.persistence.Version; +import java.time.Instant; + +/** + * JPA row for {@code durable_operation}; schema owned by Flyway ({@code + * V11__durable_operation.sql}). + * + *

{@code tenant} is the empty string rather than {@code null} for single-tenant operations, for + * the same reason the idempotency row does it: a nullable column in a unique constraint stops + * enforcing uniqueness in PostgreSQL, and the constraint is what makes a resubmission return the + * existing operation instead of creating a second one. + * + *

The lease columns are on the row rather than in a side table so that claiming, heartbeating + * and completing are all single-row updates whose {@code WHERE} clause can carry the ownership + * check. Split across two tables, an expired lease and a completing worker could interleave. + */ +@Entity +@Table( + name = "durable_operation", + uniqueConstraints = + @UniqueConstraint( + name = "uq_durable_operation_scope", + columnNames = {"tenant", "principal", "operation_name", "request_hash"})) +public class DurableOperationEntity { + + @Id + @Column(name = "operation_id", nullable = false, updatable = false, length = 128) + private String operationId; + + @Column(name = "tenant", nullable = false, length = 128) + private String tenant; + + @Column(name = "principal", nullable = false, length = 256) + private String principal; + + @Column(name = "operation_name", nullable = false, length = 256) + private String operationName; + + @Column(name = "request_hash", nullable = false, length = 64) + private String requestHash; + + @Column(name = "payload", nullable = false) + private String payload; + + @Column(name = "state", nullable = false, length = 16) + private String state; + + @Column(name = "submitted_at", nullable = false) + private Instant submittedAt; + + @Column(name = "started_at") + private Instant startedAt; + + @Column(name = "completed_at") + private Instant completedAt; + + @Column(name = "progress_completed") + private Long progressCompleted; + + @Column(name = "progress_total") + private Long progressTotal; + + @Column(name = "progress_phase", length = 128) + private String progressPhase; + + @Column(name = "result_reference", length = 1024) + private String resultReference; + + @Column(name = "failure_code", length = 128) + private String failureCode; + + @Column(name = "failure_detail", length = 2048) + private String failureDetail; + + @Column(name = "failure_retryable") + private Boolean failureRetryable; + + @Column(name = "lease_owner", length = 256) + private String leaseOwner; + + @Column(name = "lease_acquired_at") + private Instant leaseAcquiredAt; + + @Column(name = "lease_expires_at") + private Instant leaseExpiresAt; + + @Column(name = "expires_at", nullable = false) + private Instant expiresAt; + + @Version + @Column(name = "row_version", nullable = false) + private long rowVersion; + + protected DurableOperationEntity() {} + + /** + * A newly submitted operation. + * + * @param operationId the identity + * @param tenant the tenant, empty string when not tenant scoped + * @param principal who submitted it + * @param operationName what work this is + * @param requestHash the semantic fingerprint + * @param payload the serialized request + * @param submittedAt when it was accepted + * @param expiresAt when the record stops being available + */ + public DurableOperationEntity( + String operationId, + String tenant, + String principal, + String operationName, + String requestHash, + String payload, + Instant submittedAt, + Instant expiresAt) { + this.operationId = operationId; + this.tenant = tenant; + this.principal = principal; + this.operationName = operationName; + this.requestHash = requestHash; + this.payload = payload; + this.state = "PENDING"; + this.submittedAt = submittedAt; + this.expiresAt = expiresAt; + } + + /** The identity. */ + public String getOperationId() { + return operationId; + } + + /** The tenant, empty string when not tenant scoped. */ + public String getTenant() { + return tenant; + } + + /** Who submitted it. */ + public String getPrincipal() { + return principal; + } + + /** What work this is. */ + public String getOperationName() { + return operationName; + } + + /** The semantic fingerprint. */ + public String getRequestHash() { + return requestHash; + } + + /** The serialized request. */ + public String getPayload() { + return payload; + } + + /** The lifecycle state. */ + public String getState() { + return state; + } + + /** When it was accepted. */ + public Instant getSubmittedAt() { + return submittedAt; + } + + /** When a worker first picked it up. */ + public Instant getStartedAt() { + return startedAt; + } + + /** When it reached a terminal state. */ + public Instant getCompletedAt() { + return completedAt; + } + + /** Units finished, when reported. */ + public Long getProgressCompleted() { + return progressCompleted; + } + + /** Units expected, when known. */ + public Long getProgressTotal() { + return progressTotal; + } + + /** The current stage label, when reported. */ + public String getProgressPhase() { + return progressPhase; + } + + /** Where a successful result is read. */ + public String getResultReference() { + return resultReference; + } + + /** The stored failure code. */ + public String getFailureCode() { + return failureCode; + } + + /** The stored, already-safe failure message. */ + public String getFailureDetail() { + return failureDetail; + } + + /** Whether resubmitting could succeed. */ + public Boolean getFailureRetryable() { + return failureRetryable; + } + + /** The worker holding the lease. */ + public String getLeaseOwner() { + return leaseOwner; + } + + /** When the lease was taken. */ + public Instant getLeaseAcquiredAt() { + return leaseAcquiredAt; + } + + /** When the lease lapses. */ + public Instant getLeaseExpiresAt() { + return leaseExpiresAt; + } + + /** When the record stops being available. */ + public Instant getExpiresAt() { + return expiresAt; + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/KeysetPredicateBuilder.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/KeysetPredicateBuilder.java index 41161cdd..07a18f7a 100644 --- a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/KeysetPredicateBuilder.java +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/springdata/KeysetPredicateBuilder.java @@ -54,9 +54,11 @@ public final class KeysetPredicateBuilder { for (int index = 0; index < terms.size(); index++) { List conjunction = new ArrayList<>(index + 1); for (int equalIndex = 0; equalIndex < index; equalIndex++) { - conjunction.add(equalTo(builder, terms.get(equalIndex))); + KeysetTerm equalTerm = terms.get(equalIndex); + conjunction.add(equalTo(builder, equalTerm)); } - conjunction.add(strict(builder, terms.get(index))); + KeysetTerm strictTerm = terms.get(index); + conjunction.add(strict(builder, strictTerm)); alternatives.add(builder.and(conjunction.toArray(new Predicate[0]))); } return builder.or(alternatives.toArray(new Predicate[0])); diff --git a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringPolicyTransactionPort.java b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringPolicyTransactionPort.java index b220bd99..905397e8 100644 --- a/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringPolicyTransactionPort.java +++ b/src/adapter/outbound/persistence-jpa/src/main/java/dev/caskeleton/adapter/outbound/persistence/transaction/SpringPolicyTransactionPort.java @@ -244,7 +244,10 @@ final class SpringPolicyTransactionPort { } } - private static final class PhaseSentinel implements TransactionSynchronization, Ordered { + // Not `implements TransactionSynchronization, Ordered`: TransactionSynchronization already + // extends Ordered, so naming it again says nothing and reads as though the ordering were + // this class's own idea rather than part of the callback contract it implements. + private static final class PhaseSentinel implements TransactionSynchronization { private final PhaseTracker tracker; private boolean commitAcknowledged; diff --git a/src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/postgresql/V11__durable_operation.sql b/src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/postgresql/V11__durable_operation.sql new file mode 100644 index 00000000..a2058af0 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/postgresql/V11__durable_operation.sql @@ -0,0 +1,73 @@ +-- Durable long-running operations. +-- +-- The row is the whole record: submission, lifecycle, progress, result reference, failure and the +-- worker lease. Keeping the lease here rather than in a side table is what lets a claim, a +-- heartbeat and a completion each be one UPDATE whose WHERE clause carries the ownership check — +-- with two tables an expired lease and a completing worker can interleave and the operation ends +-- up with two answers. +CREATE TABLE IF NOT EXISTS durable_operation ( + operation_id VARCHAR(128) PRIMARY KEY, + -- Empty string, never NULL, for single-tenant operations: PostgreSQL treats NULLs as distinct + -- in a unique constraint, so a nullable column here would silently stop deduplicating + -- resubmissions. + tenant VARCHAR(128) NOT NULL, + principal VARCHAR(256) NOT NULL, + operation_name VARCHAR(256) NOT NULL, + request_hash VARCHAR(64) NOT NULL, + payload TEXT NOT NULL, + state VARCHAR(16) NOT NULL, + submitted_at TIMESTAMPTZ NOT NULL, + started_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + progress_completed BIGINT, + progress_total BIGINT, + progress_phase VARCHAR(128), + result_reference VARCHAR(1024), + failure_code VARCHAR(128), + failure_detail VARCHAR(2048), + failure_retryable BOOLEAN, + lease_owner VARCHAR(256), + lease_acquired_at TIMESTAMPTZ, + lease_expires_at TIMESTAMPTZ, + expires_at TIMESTAMPTZ NOT NULL, + row_version BIGINT NOT NULL DEFAULT 0, + + CONSTRAINT uq_durable_operation_scope + UNIQUE (tenant, principal, operation_name, request_hash), + + -- The states a client can be shown are the states the table can hold. A worker that invents + -- one would otherwise extend the published contract by writing a row. + CONSTRAINT ck_durable_operation_state + CHECK (state IN ('PENDING', 'RUNNING', 'SUCCEEDED', 'FAILED', 'CANCELED', 'EXPIRED')), + + -- The invariants the application record also enforces, restated here because the database is + -- the one place a second writer cannot bypass. + CONSTRAINT ck_durable_operation_succeeded_has_result + CHECK (state <> 'SUCCEEDED' OR result_reference IS NOT NULL), + CONSTRAINT ck_durable_operation_failed_has_reason + CHECK (state <> 'FAILED' OR failure_code IS NOT NULL), + CONSTRAINT ck_durable_operation_running_has_lease + CHECK (state <> 'RUNNING' OR lease_owner IS NOT NULL), + CONSTRAINT ck_durable_operation_expiry_after_submission + CHECK (expires_at > submitted_at) +); + +-- The claim query's access path: the oldest runnable operation. Partial, because the terminal rows +-- are the ones that accumulate and a worker never looks at them. +CREATE INDEX IF NOT EXISTS ix_durable_operation_runnable + ON durable_operation (submitted_at) + WHERE state IN ('PENDING', 'RUNNING'); + +-- Reclaiming lapsed leases and expiring aged-out records both scan by time. +CREATE INDEX IF NOT EXISTS ix_durable_operation_lease_expiry + ON durable_operation (lease_expires_at) + WHERE lease_expires_at IS NOT NULL; + +CREATE INDEX IF NOT EXISTS ix_durable_operation_expiry + ON durable_operation (expires_at) + WHERE state NOT IN ('EXPIRED', 'CANCELED'); + +-- Polling reads by identity and principal; the principal is in the predicate so one caller cannot +-- read another's operation by guessing an id. +CREATE INDEX IF NOT EXISTS ix_durable_operation_principal + ON durable_operation (principal, operation_id); diff --git a/src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/postgresql/V12__live_event_log.sql b/src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/postgresql/V12__live_event_log.sql new file mode 100644 index 00000000..9f3a4b53 --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/main/resources/db/migration/postgresql/V12__live_event_log.sql @@ -0,0 +1,27 @@ +-- Retained live-event history, for a client that reconnects and wants to continue. +-- +-- The position is assigned per stream and is dense within it. A global sequence would be simpler +-- and would leak: a client resuming a feed it is entitled to would see gaps whose size reveals how +-- much traffic every other stream carried, which for a per-tenant feed is a usable side channel. +-- +-- Retention is a column rather than a policy elsewhere. A reader has to be able to distinguish "you +-- are up to date" from "your cursor is older than what we kept", and the second answer is only +-- available if the row that would have answered it is known to be gone rather than merely absent. +CREATE TABLE IF NOT EXISTS live_event_log ( + stream_id VARCHAR(128) NOT NULL, + position BIGINT NOT NULL, + payload TEXT NOT NULL, + occurred_at TIMESTAMPTZ NOT NULL, + retained_until TIMESTAMPTZ NOT NULL, + CONSTRAINT pk_live_event_log PRIMARY KEY (stream_id, position), + -- Positions count from 1 so an unset column cannot pass for the first event. A stream whose + -- first row claimed 0 and one whose position was never assigned look identical to a reader. + CONSTRAINT ck_live_event_log_position CHECK (position >= 1), + CONSTRAINT ck_live_event_log_retention CHECK (retained_until > occurred_at) +); + +-- The replay read: everything after a position, in order, for one stream. Covered by the primary +-- key, so this index is not for the read — it is for the sweep below, which is by time and would +-- otherwise scan the table on every pass. +CREATE INDEX IF NOT EXISTS ix_live_event_log_retention + ON live_event_log (retained_until); diff --git a/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/liveevent/LiveEventLogContractTest.java b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/liveevent/LiveEventLogContractTest.java new file mode 100644 index 00000000..78ea281d --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/liveevent/LiveEventLogContractTest.java @@ -0,0 +1,300 @@ +package dev.caskeleton.adapter.outbound.persistence.liveevent; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.persistence.platform.JpaPlatformContractSupport; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.sql.Timestamp; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * The live-event log against a real PostgreSQL. + * + *

Every claim this store makes is about a constraint or about concurrency, and neither survives + * a fake. A {@code CHECK} a fake does not enforce is a comment; two writers racing for the same + * position has no meaning where only one connection exists — and that race is the whole reason the + * position is part of the primary key rather than assigned in Java. + */ +@Tag("jpa-contract") +class LiveEventLogContractTest { + + private static final Instant NOW = Instant.parse("2026-08-25T09:00:00Z"); + private static final Duration RETENTION = Duration.ofHours(1); + + private static JpaPlatformContractSupport support; + + @BeforeAll + static void startServer() { + support = JpaPlatformContractSupport.start(); + } + + @AfterAll + static void stopServer() { + if (support != null) { + support.close(); + } + } + + @BeforeEach + void migrate() throws SQLException, IOException { + try (Connection connection = support.connection(); + Statement statement = connection.createStatement()) { + statement.execute("DROP SCHEMA public CASCADE"); + statement.execute("CREATE SCHEMA public"); + statement.execute(migration()); + } + } + + @Test + @DisplayName("two positions cannot collide on one stream") + void positionsAreUniquePerStream() throws SQLException { + append("orders", 1, "a", NOW); + + assertThatThrownBy(() -> append("orders", 1, "b", NOW)).isInstanceOf(SQLException.class); + } + + @Test + @DisplayName("the same position on a different stream is fine") + void positionsAreScopedToTheirStream() throws SQLException { + append("orders", 1, "a", NOW); + append("invoices", 1, "b", NOW); + + assertThat(replayAfter("orders", 0, NOW)).hasSize(1); + assertThat(replayAfter("invoices", 0, NOW)).hasSize(1); + } + + @Test + @DisplayName("two writers racing for the same position produce one row, not two") + void concurrentAppendsCannotShareAPosition() throws Exception { + // The property the primary key exists for. Assigning positions in Java without it would let + // both writers read the same maximum and both write it, and the second event would silently + // overwrite the first — two different events at one address, with a client's cursor pointing + // at whichever survived. + List outcomes = new ArrayList<>(); + try (Connection first = support.connection(); + Connection second = support.connection()) { + first.setAutoCommit(false); + second.setAutoCommit(false); + insert(first, "orders", 1, "from-first", NOW); + outcomes.add("first-inserted"); + first.commit(); + try { + insert(second, "orders", 1, "from-second", NOW); + second.commit(); + outcomes.add("second-inserted"); + } catch (SQLException refused) { + second.rollback(); + outcomes.add("second-refused"); + } + } + + assertThat(outcomes).containsExactly("first-inserted", "second-refused"); + assertThat(payloadAt("orders", 1)).isEqualTo("from-first"); + } + + @Test + @DisplayName("a position of zero is refused by the database, not only by the code") + void positionZeroIsRefused() { + // A stream whose first row claimed 0 and one whose position was never assigned look identical + // to a reader, so the floor is a constraint rather than a convention. + assertThatThrownBy(() -> append("orders", 0, "a", NOW)).isInstanceOf(SQLException.class); + } + + @Test + @DisplayName("a row that expires before it happened is refused") + void backwardsRetentionIsRefused() throws SQLException { + try (Connection connection = support.connection(); + PreparedStatement statement = + connection.prepareStatement( + "INSERT INTO live_event_log (stream_id, position, payload, occurred_at," + + " retained_until) VALUES (?, ?, ?, ?, ?)")) { + statement.setString(1, "orders"); + statement.setLong(2, 1); + statement.setString(3, "a"); + statement.setTimestamp(4, Timestamp.from(NOW)); + statement.setTimestamp(5, Timestamp.from(NOW.minusSeconds(1))); + + assertThatThrownBy(statement::executeUpdate).isInstanceOf(SQLException.class); + } + } + + @Test + @DisplayName("a replay returns everything after a cursor, in order") + void replayIsOrderedAndExclusive() throws SQLException { + append("orders", 1, "a", NOW); + append("orders", 2, "b", NOW); + append("orders", 3, "c", NOW); + + assertThat(replayAfter("orders", 1, NOW)).containsExactly("b", "c"); + assertThat(replayAfter("orders", 3, NOW)).isEmpty(); + } + + @Test + @DisplayName("an expired row is not replayed, even before it is swept") + void expiredRowsAreNotReplayed() throws SQLException { + append("orders", 1, "old", NOW.minus(Duration.ofHours(2))); + append("orders", 2, "new", NOW); + + // Filtered on read as well as swept on a schedule. An expired row that has not been collected + // yet is not history the client may have, and serving it would make the window this store + // reports and the window it honours two different things. + assertThat(replayAfter("orders", 0, NOW)).containsExactly("new"); + assertThat(earliestRetained("orders", NOW)).isEqualTo(2L); + } + + @Test + @DisplayName("a swept position is never handed out again") + void sweptPositionsAreNotReused() throws SQLException { + append("orders", 1, "gone", NOW.minus(Duration.ofHours(2))); + append("orders", 2, "kept", NOW); + assertThat(sweep(NOW)).isOne(); + + // The highest-ever query deliberately ignores retention. Reusing position 1 would give two + // different events the same address, and a client holding the older cursor would receive the + // newer event as though it were the one it asked to continue after. + assertThat(highestEverAssigned("orders")).isEqualTo(2L); + } + + @Test + @DisplayName("the sweep removes only what has expired") + void sweepIsSelective() throws SQLException { + append("orders", 1, "gone", NOW.minus(Duration.ofHours(2))); + append("orders", 2, "kept", NOW); + append("invoices", 1, "kept", NOW); + + assertThat(sweep(NOW)).isOne(); + assertThat(replayAfter("orders", 0, NOW)).containsExactly("kept"); + assertThat(replayAfter("invoices", 0, NOW)).containsExactly("kept"); + } + + @Test + @DisplayName("an empty stream reports no window at all") + void emptyStreamHasNoWindow() throws SQLException { + assertThat(earliestRetained("nothing-here", NOW)).isNull(); + } + + private void append(String streamId, long position, String payload, Instant occurredAt) + throws SQLException { + try (Connection connection = support.connection()) { + insert(connection, streamId, position, payload, occurredAt); + } + } + + private static void insert( + Connection connection, String streamId, long position, String payload, Instant occurredAt) + throws SQLException { + try (PreparedStatement statement = + connection.prepareStatement( + "INSERT INTO live_event_log (stream_id, position, payload, occurred_at," + + " retained_until) VALUES (?, ?, ?, ?, ?)")) { + statement.setString(1, streamId); + statement.setLong(2, position); + statement.setString(3, payload); + statement.setTimestamp(4, Timestamp.from(occurredAt)); + statement.setTimestamp(5, Timestamp.from(occurredAt.plus(RETENTION))); + statement.executeUpdate(); + } + } + + private List replayAfter(String streamId, long afterPosition, Instant now) + throws SQLException { + List payloads = new ArrayList<>(); + try (Connection connection = support.connection(); + PreparedStatement statement = + connection.prepareStatement( + "SELECT payload FROM live_event_log WHERE stream_id = ? AND position > ?" + + " AND retained_until > ? ORDER BY position ASC")) { + statement.setString(1, streamId); + statement.setLong(2, afterPosition); + statement.setTimestamp(3, Timestamp.from(now)); + try (ResultSet rows = statement.executeQuery()) { + while (rows.next()) { + payloads.add(rows.getString(1)); + } + } + } + return payloads; + } + + private Long earliestRetained(String streamId, Instant now) throws SQLException { + try (Connection connection = support.connection(); + PreparedStatement statement = + connection.prepareStatement( + "SELECT min(position) FROM live_event_log WHERE stream_id = ?" + + " AND retained_until > ?")) { + statement.setString(1, streamId); + statement.setTimestamp(2, Timestamp.from(now)); + try (ResultSet rows = statement.executeQuery()) { + rows.next(); + long value = rows.getLong(1); + return rows.wasNull() ? null : value; + } + } + } + + private Long highestEverAssigned(String streamId) throws SQLException { + try (Connection connection = support.connection(); + PreparedStatement statement = + connection.prepareStatement( + "SELECT max(position) FROM live_event_log WHERE stream_id = ?")) { + statement.setString(1, streamId); + try (ResultSet rows = statement.executeQuery()) { + rows.next(); + long value = rows.getLong(1); + return rows.wasNull() ? null : value; + } + } + } + + private String payloadAt(String streamId, long position) throws SQLException { + try (Connection connection = support.connection(); + PreparedStatement statement = + connection.prepareStatement( + "SELECT payload FROM live_event_log WHERE stream_id = ? AND position = ?")) { + statement.setString(1, streamId); + statement.setLong(2, position); + try (ResultSet rows = statement.executeQuery()) { + return rows.next() ? rows.getString(1) : null; + } + } + } + + private int sweep(Instant now) throws SQLException { + try (Connection connection = support.connection(); + PreparedStatement statement = + connection.prepareStatement("DELETE FROM live_event_log WHERE retained_until <= ?")) { + statement.setTimestamp(1, Timestamp.from(now)); + return statement.executeUpdate(); + } + } + + private static String migration() throws IOException { + try (InputStream source = + LiveEventLogContractTest.class.getResourceAsStream( + "/db/migration/postgresql/V12__live_event_log.sql")) { + if (source == null) { + throw new IllegalStateException( + "the migration this store is defined by is missing from the classpath, so the test" + + " would create its own idea of the schema and certify that instead"); + } + return new String(source.readAllBytes(), StandardCharsets.UTF_8); + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/operation/DurableOperationStoreContractTest.java b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/operation/DurableOperationStoreContractTest.java new file mode 100644 index 00000000..0e3b7a3d --- /dev/null +++ b/src/adapter/outbound/persistence-jpa/src/postgresqlIntegrationTest/java/dev/caskeleton/adapter/outbound/persistence/operation/DurableOperationStoreContractTest.java @@ -0,0 +1,474 @@ +package dev.caskeleton.adapter.outbound.persistence.operation; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.adapter.outbound.persistence.platform.JpaPlatformContractSupport; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.sql.Timestamp; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * The durable operation store against a real PostgreSQL. + * + *

Every claim in this store is about concurrency or about a constraint, and neither survives + * being tested against a fake. {@code FOR UPDATE SKIP LOCKED} does not exist in H2's PostgreSQL + * mode with the semantics that matter, a {@code CHECK} constraint a fake does not enforce is a + * comment, and a lease race has no meaning where only one connection exists. + * + *

A dead worker is written the only way that is faithful here: the row a crash leaves behind — a + * lease nobody will renew — and then recovery runs against it. That is precisely what a killed + * process is from the database's side. + */ +@Tag("jpa-contract") +class DurableOperationStoreContractTest { + + private static final Instant NOW = Instant.parse("2026-08-25T09:00:00Z"); + private static final Duration LEASE = Duration.ofMinutes(2); + + private static JpaPlatformContractSupport support; + + @BeforeAll + static void startServer() { + support = JpaPlatformContractSupport.start(); + } + + @AfterAll + static void stopServer() { + if (support != null) { + support.close(); + } + } + + @BeforeEach + void migrate() throws SQLException, IOException { + try (Connection connection = support.connection(); + Statement statement = connection.createStatement()) { + statement.execute("DROP SCHEMA public CASCADE"); + statement.execute("CREATE SCHEMA public"); + statement.execute(migration()); + } + } + + @Test + @DisplayName("a lease left behind by a dead worker is reclaimed by another") + void expiredLeaseCanBeReclaimedAfterWorkerCrash() throws SQLException { + submit("op-1", "hash-1"); + claim("worker-a", NOW); + + // The worker is gone. Nothing renews the lease, and nothing else may touch the operation until + // it lapses — that exclusivity is the point of the lease. + assertThat(claim("worker-b", NOW.plusSeconds(30))).isEmpty(); + + assertThat(claim("worker-b", NOW.plus(LEASE).plusSeconds(1))).contains("op-1"); + assertThat(state("op-1")).isEqualTo("RUNNING"); + assertThat(leaseOwner("op-1")).isEqualTo("worker-b"); + } + + @Test + @DisplayName("a reclaimed operation keeps the moment it first started") + void reclaimKeepsTheOriginalStartTime() throws SQLException { + submit("op-1", "hash-1"); + claim("worker-a", NOW); + + claim("worker-b", NOW.plus(LEASE).plusSeconds(1)); + + // COALESCE, not an unconditional assignment. Overwriting it would make every retry look like a + // fresh start and hide how long the operation has really been running. + assertThat(startedAt("op-1")).isEqualTo(NOW); + } + + @Test + @DisplayName("two workers claiming at once take different operations") + void concurrentWorkersDoNotTakeTheSameOperation() throws Exception { + submit("op-1", "hash-1"); + submit("op-2", "hash-2"); + + List claimed = new ArrayList<>(); + try (Connection first = support.connection(); + Connection second = support.connection()) { + first.setAutoCommit(false); + second.setAutoCommit(false); + // Interleaved on purpose and inside open transactions: with plain FOR UPDATE the second + // claim blocks here until the first commits, and with no locking at all both take op-1. + claimed.add(claimOn(first, "worker-a", NOW).orElseThrow()); + claimed.add(claimOn(second, "worker-b", NOW).orElseThrow()); + first.commit(); + second.commit(); + } + + assertThat(claimed).containsExactlyInAnyOrder("op-1", "op-2"); + } + + @Test + @DisplayName("a worker whose lease lapsed cannot record a result over its successor") + void aStaleWorkerCannotOverwriteTheNewOwner() throws SQLException { + submit("op-1", "hash-1"); + claim("worker-a", NOW); + claim("worker-b", NOW.plus(LEASE).plusSeconds(1)); + + // worker-a is alive and still finishing. Letting it report here is how one operation ends up + // with two answers, and which one survives would come down to timing. + assertThat(succeed("op-1", "worker-a", "/results/a")).isZero(); + assertThat(succeed("op-1", "worker-b", "/results/b")).isOne(); + assertThat(resultReference("op-1")).isEqualTo("/results/b"); + } + + @Test + @DisplayName("a heartbeat from a worker that lost the lease fails rather than extending it") + void heartbeatFromAStaleWorkerFails() throws SQLException { + submit("op-1", "hash-1"); + claim("worker-a", NOW); + claim("worker-b", NOW.plus(LEASE).plusSeconds(1)); + + assertThat(heartbeat("op-1", "worker-a", NOW.plus(LEASE).plusSeconds(2))).isZero(); + assertThat(heartbeat("op-1", "worker-b", NOW.plus(LEASE).plusSeconds(2))).isOne(); + } + + @Test + @DisplayName("cancel works once and never reverses a finished operation") + void cancelIsIdempotentAndNeverReversesTerminal() throws SQLException { + submit("op-1", "hash-1"); + claim("worker-a", NOW); + succeed("op-1", "worker-a", "/results/a"); + + // The work happened. Recording it as CANCELED would replace the only record of a real outcome + // with a claim that it never occurred. + assertThat(cancel("op-1", "alice")).isZero(); + assertThat(state("op-1")).isEqualTo("SUCCEEDED"); + + submit("op-2", "hash-2"); + assertThat(cancel("op-2", "alice")).isOne(); + assertThat(cancel("op-2", "alice")).isZero(); + assertThat(state("op-2")).isEqualTo("CANCELED"); + } + + @Test + @DisplayName("another principal cannot cancel an operation they did not submit") + void cancelIsScopedToThePrincipal() throws SQLException { + submit("op-1", "hash-1"); + + assertThat(cancel("op-1", "mallory")).isZero(); + assertThat(state("op-1")).isEqualTo("PENDING"); + } + + @Test + @DisplayName("a cancelled operation is not handed to a worker") + void cancelledOperationsAreNotClaimable() throws SQLException { + submit("op-1", "hash-1"); + cancel("op-1", "alice"); + + assertThat(claim("worker-a", NOW)).isEmpty(); + } + + @Test + @DisplayName("an identical resubmission is refused by the unique constraint") + void resubmissionCollidesOnScope() throws SQLException { + submit("op-1", "hash-1"); + + // The constraint is the arbiter rather than a prior read: two identical submissions can arrive + // at the same instant, and a read-then-insert lets both through. + assertThatThrownBy(() -> submit("op-2", "hash-1")).isInstanceOf(SQLException.class); + } + + @Test + @DisplayName("a succeeded row with no result is refused by the database") + void succeededRowRequiresAResult() throws SQLException { + submit("op-1", "hash-1"); + + assertThatThrownBy( + () -> + execute( + "UPDATE durable_operation SET state = 'SUCCEEDED', completed_at = now()," + + " lease_owner = NULL WHERE operation_id = 'op-1'")) + .isInstanceOf(SQLException.class) + .hasMessageContaining("ck_durable_operation_succeeded_has_result"); + } + + @Test + @DisplayName("a running row with no lease is refused by the database") + void runningRowRequiresALease() { + assertThatThrownBy( + () -> { + submit("op-1", "hash-1"); + execute("UPDATE durable_operation SET state = 'RUNNING' WHERE operation_id = 'op-1'"); + }) + .isInstanceOf(SQLException.class) + .hasMessageContaining("ck_durable_operation_running_has_lease"); + } + + @Test + @DisplayName("a state outside the published vocabulary is refused") + void stateVocabularyIsClosed() { + // A worker that could write its own state would extend the published contract by writing a + // row, and a client branching on status would meet a value that is in no version of the API. + assertThatThrownBy( + () -> { + submit("op-1", "hash-1"); + execute( + "UPDATE durable_operation SET state = 'ALMOST_DONE' WHERE operation_id = 'op-1'"); + }) + .isInstanceOf(SQLException.class) + .hasMessageContaining("ck_durable_operation_state"); + } + + @Test + @DisplayName("an aged-out operation is expired, and a finished one is left alone") + void expirySweepLeavesRealOutcomesAlone() throws SQLException { + submit("op-1", "hash-1"); + submit("op-2", "hash-2"); + claim("worker-a", NOW); + + List expired = expireStale(NOW.plus(Duration.ofDays(2))); + + assertThat(expired).containsExactlyInAnyOrder("op-1", "op-2"); + submit("op-3", "hash-3"); + claim("worker-b", NOW); + succeed("op-3", "worker-b", "/results/c"); + assertThat(expireStale(NOW.plus(Duration.ofDays(2)))).isEmpty(); + assertThat(state("op-3")).isEqualTo("SUCCEEDED"); + } + + @Test + @DisplayName("reclaim returns lapsed leases to the queue") + void reclaimReturnsLapsedLeases() throws SQLException { + submit("op-1", "hash-1"); + claim("worker-a", NOW); + + assertThat(reclaim(NOW.plusSeconds(30))).isEmpty(); + assertThat(reclaim(NOW.plus(LEASE).plusSeconds(1))).containsExactly("op-1"); + assertThat(state("op-1")).isEqualTo("PENDING"); + assertThat(leaseOwner("op-1")).isNull(); + } + + private void submit(String operationId, String hash) throws SQLException { + try (Connection connection = support.connection(); + PreparedStatement statement = + connection.prepareStatement( + """ + INSERT INTO durable_operation + (operation_id, tenant, principal, operation_name, request_hash, payload, + state, submitted_at, expires_at) + VALUES (?, '', 'alice', 'transfers.create', ?, '{}', 'PENDING', ?, ?) + """)) { + statement.setString(1, operationId); + statement.setString(2, hash); + statement.setTimestamp(3, Timestamp.from(NOW)); + statement.setTimestamp(4, Timestamp.from(NOW.plus(Duration.ofDays(1)))); + statement.executeUpdate(); + } + } + + private Optional claim(String workerId, Instant now) throws SQLException { + try (Connection connection = support.connection()) { + return claimOn(connection, workerId, now); + } + } + + private Optional claimOn(Connection connection, String workerId, Instant now) + throws SQLException { + try (PreparedStatement statement = + connection.prepareStatement( + """ + UPDATE durable_operation + SET state = 'RUNNING', + lease_owner = ?, + lease_acquired_at = ?, + lease_expires_at = ?, + started_at = COALESCE(started_at, ?), + row_version = row_version + 1 + WHERE operation_id = ( + SELECT operation_id + FROM durable_operation + WHERE (state = 'PENDING' + OR (state = 'RUNNING' AND lease_expires_at <= ?)) + AND expires_at > ? + ORDER BY submitted_at + FOR UPDATE SKIP LOCKED + LIMIT 1) + RETURNING operation_id + """)) { + statement.setString(1, workerId); + statement.setTimestamp(2, Timestamp.from(now)); + statement.setTimestamp(3, Timestamp.from(now.plus(LEASE))); + statement.setTimestamp(4, Timestamp.from(now)); + statement.setTimestamp(5, Timestamp.from(now)); + statement.setTimestamp(6, Timestamp.from(now)); + try (ResultSet rows = statement.executeQuery()) { + return rows.next() ? Optional.of(rows.getString(1)) : Optional.empty(); + } + } + } + + private int succeed(String operationId, String workerId, String resultReference) + throws SQLException { + try (Connection connection = support.connection(); + PreparedStatement statement = + connection.prepareStatement( + """ + UPDATE durable_operation + SET state = 'SUCCEEDED', result_reference = ?, completed_at = ?, + lease_owner = NULL, lease_acquired_at = NULL, lease_expires_at = NULL, + row_version = row_version + 1 + WHERE operation_id = ? AND state = 'RUNNING' AND lease_owner = ? + """)) { + statement.setString(1, resultReference); + statement.setTimestamp(2, Timestamp.from(NOW.plusSeconds(10))); + statement.setString(3, operationId); + statement.setString(4, workerId); + return statement.executeUpdate(); + } + } + + private int heartbeat(String operationId, String workerId, Instant now) throws SQLException { + try (Connection connection = support.connection(); + PreparedStatement statement = + connection.prepareStatement( + """ + UPDATE durable_operation + SET lease_expires_at = ?, row_version = row_version + 1 + WHERE operation_id = ? AND state = 'RUNNING' AND lease_owner = ? + AND lease_expires_at > ? + """)) { + statement.setTimestamp(1, Timestamp.from(now.plus(LEASE))); + statement.setString(2, operationId); + statement.setString(3, workerId); + statement.setTimestamp(4, Timestamp.from(now)); + return statement.executeUpdate(); + } + } + + private int cancel(String operationId, String principal) throws SQLException { + try (Connection connection = support.connection(); + PreparedStatement statement = + connection.prepareStatement( + """ + UPDATE durable_operation + SET state = 'CANCELED', completed_at = ?, lease_owner = NULL, + lease_acquired_at = NULL, lease_expires_at = NULL, + row_version = row_version + 1 + WHERE operation_id = ? AND principal = ? AND state IN ('PENDING', 'RUNNING') + """)) { + statement.setTimestamp(1, Timestamp.from(NOW.plusSeconds(5))); + statement.setString(2, operationId); + statement.setString(3, principal); + return statement.executeUpdate(); + } + } + + private List reclaim(Instant now) throws SQLException { + return idsFrom( + """ + UPDATE durable_operation + SET state = 'PENDING', lease_owner = NULL, lease_acquired_at = NULL, + lease_expires_at = NULL, row_version = row_version + 1 + WHERE state = 'RUNNING' AND lease_expires_at <= ? + RETURNING operation_id + """, + now); + } + + private List expireStale(Instant now) throws SQLException { + return idsFrom( + """ + UPDATE durable_operation + SET state = 'EXPIRED', completed_at = ?, lease_owner = NULL, + lease_acquired_at = NULL, lease_expires_at = NULL, + row_version = row_version + 1 + WHERE state IN ('PENDING', 'RUNNING') AND expires_at <= ? + RETURNING operation_id + """, + now, + now); + } + + private List idsFrom(String sql, Instant... arguments) throws SQLException { + try (Connection connection = support.connection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + for (int index = 0; index < arguments.length; index++) { + statement.setTimestamp(index + 1, Timestamp.from(arguments[index])); + } + List ids = new ArrayList<>(); + try (ResultSet rows = statement.executeQuery()) { + while (rows.next()) { + ids.add(rows.getString(1)); + } + } + return ids; + } + } + + private String state(String operationId) throws SQLException { + return column(operationId, "state"); + } + + private String leaseOwner(String operationId) throws SQLException { + return column(operationId, "lease_owner"); + } + + private String resultReference(String operationId) throws SQLException { + return column(operationId, "result_reference"); + } + + private Instant startedAt(String operationId) throws SQLException { + try (Connection connection = support.connection(); + PreparedStatement statement = + connection.prepareStatement( + "SELECT started_at FROM durable_operation WHERE operation_id = ?")) { + statement.setString(1, operationId); + try (ResultSet rows = statement.executeQuery()) { + rows.next(); + return rows.getTimestamp(1).toInstant(); + } + } + } + + private String column(String operationId, String name) throws SQLException { + try (Connection connection = support.connection(); + PreparedStatement statement = + connection.prepareStatement( + "SELECT " + name + " FROM durable_operation WHERE operation_id = ?")) { + statement.setString(1, operationId); + try (ResultSet rows = statement.executeQuery()) { + rows.next(); + return rows.getString(1); + } + } + } + + private void execute(String sql) throws SQLException { + try (Connection connection = support.connection(); + Statement statement = connection.createStatement()) { + statement.execute(sql); + } + } + + private static String migration() throws IOException { + try (InputStream resource = + DurableOperationStoreContractTest.class + .getClassLoader() + .getResourceAsStream("db/migration/postgresql/V11__durable_operation.sql")) { + if (resource == null) { + throw new IOException("the durable operation migration is missing from the classpath"); + } + return new String(resource.readAllBytes(), StandardCharsets.UTF_8); + } + } +} diff --git a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/JpaModuleBoundaryTest.java b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/JpaModuleBoundaryTest.java index 1ed0e0cb..72c94470 100644 --- a/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/JpaModuleBoundaryTest.java +++ b/src/adapter/outbound/persistence-jpa/src/test/java/dev/caskeleton/adapter/outbound/persistence/JpaModuleBoundaryTest.java @@ -88,6 +88,15 @@ class JpaModuleBoundaryTest { Map.entry("migration", Set.of("api")), Map.entry("notification", Set.of("api", "audit", "failure", "springdata")), Map.entry("observation", Set.of("api")), + // Retained live-event history. No edge to `outbox` even though both are append-and-read + // logs: the outbox is a delivery queue whose rows are consumed and retired, and this is a + // replay window whose rows are read many times and expire on a clock. Sharing a package + // would put one retention policy on two different lifecycles. + Map.entry("liveevent", Set.of()), + // Durable long-running operations. No edge to `idempotency` even though the two share a + // fingerprint type: that type comes from application-core, and an edge here would let a + // change to the idempotency store's row shape reach the operation queue. + Map.entry("operation", Set.of()), Map.entry("outbox", Set.of("api", "audit", "failure")), Map.entry( "postgresql", diff --git a/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/postgresql/PostgreSqlContainerFactory.java b/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/postgresql/PostgreSqlContainerFactory.java index 7a88ec47..fa06ebf5 100644 --- a/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/postgresql/PostgreSqlContainerFactory.java +++ b/src/adapter/outbound/persistence-jpa/src/testkit/java/dev/caskeleton/adapter/outbound/persistence/testkit/postgresql/PostgreSqlContainerFactory.java @@ -24,7 +24,14 @@ public final class PostgreSqlContainerFactory { private PostgreSqlContainerFactory() {} - /** A container for one Stable version. */ + /** + * A container for one Stable version. + * + *

The container is the return value, so its lifecycle belongs to the caller — every lane that + * uses this either declares it {@code @Container} or closes it in a fixture teardown. A factory + * cannot close what it is handing over. + */ + @SuppressWarnings("resource") public static PostgreSQLContainer create(PostgreSqlVersion version) { Objects.requireNonNull(version, "version"); assertDockerAvailable(); diff --git a/src/adapter/outbound/persistence-mongo/gradle.lockfile b/src/adapter/outbound/persistence-mongo/gradle.lockfile index 2962c293..330081a7 100644 --- a/src/adapter/outbound/persistence-mongo/gradle.lockfile +++ b/src/adapter/outbound/persistence-mongo/gradle.lockfile @@ -2,22 +2,21 @@ # Manual edits can break the build and are not advised. # This file is expected to be part of source control. biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,mongoPerformanceTestCompileClasspath,testCompileClasspath,testkitCompileClasspath -ch.qos.logback:logback-classic:1.5.21=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -ch.qos.logback:logback-core:1.5.21=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -com.fasterxml.jackson.core:jackson-annotations:2.20=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,mongoPerformanceTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor -com.github.docker-java:docker-java-api:3.7.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -com.github.docker-java:docker-java-transport-zerodep:3.7.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -com.github.docker-java:docker-java-transport:3.7.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +ch.qos.logback:logback-classic:1.5.38=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +ch.qos.logback:logback-core:1.5.38=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +com.fasterxml.jackson.core:jackson-annotations:2.21=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,mongoPerformanceTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor +com.github.docker-java:docker-java-api:3.7.1=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +com.github.docker-java:docker-java-transport-zerodep:3.7.1=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +com.github.docker-java:docker-java-transport:3.7.1=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,mongoPerformanceTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs -com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,mongoPerformanceTestCompileClasspath,testCompileClasspath,testkitCompileClasspath com.github.spotbugs:spotbugs:4.10.2=spotbugs com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,mongoPerformanceTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,mongoPerformanceTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor com.google.auto:auto-common:1.2.2=annotationProcessor,mongoPerformanceTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor -com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,mongoPerformanceTestCompileClasspath,spotbugs,testCompileClasspath,testkitCompileClasspath +com.google.code.findbugs:jsr305:3.0.2=checkstyle,spotbugs com.google.code.gson:gson:2.13.2=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,mongoPerformanceTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath @@ -34,7 +33,7 @@ com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=anno com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,mongoPerformanceTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,mongoPerformanceTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins -com.jayway.jsonpath:json-path:2.9.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +com.jayway.jsonpath:json-path:2.10.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath com.puppycrawl.tools:checkstyle:13.5.0=checkstyle com.tngtech.archunit:archunit-junit5-api:1.3.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath com.tngtech.archunit:archunit-junit5-engine-api:1.3.0=mongoPerformanceTestRuntimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath @@ -47,21 +46,21 @@ commons-codec:commons-codec:1.19.0=mongoPerformanceTestCompileClasspath,mongoPer commons-collections:commons-collections:3.2.2=checkstyle commons-io:commons-io:2.20.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath commons-io:commons-io:2.21.0=spotbugs -commons-logging:commons-logging:1.3.5=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +commons-logging:commons-logging:1.3.6=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath eu.rekawek.toxiproxy:toxiproxy-java:2.1.11=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath info.picocli:picocli:4.7.7=checkstyle io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,mongoPerformanceTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,mongoPerformanceTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor -io.micrometer:micrometer-commons:1.16.0=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -io.micrometer:micrometer-core:1.16.0=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -io.micrometer:micrometer-observation:1.16.0=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -io.projectreactor:reactor-core:3.8.0=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -io.projectreactor:reactor-test:3.8.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +io.micrometer:micrometer-commons:1.16.7=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +io.micrometer:micrometer-core:1.16.7=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +io.micrometer:micrometer-observation:1.16.7=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +io.projectreactor:reactor-core:3.8.7=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +io.projectreactor:reactor-test:3.8.7=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath jakarta.activation:jakarta.activation-api:2.1.4=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +jakarta.xml.bind:jakarta.xml.bind-api:4.0.5=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath javax.inject:javax.inject:1=annotationProcessor,mongoPerformanceTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor -jaxen:jaxen:2.0.0=spotbugs +jaxen:jaxen:2.0.6=spotbugs net.bytebuddy:byte-buddy-agent:1.17.8=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath net.bytebuddy:byte-buddy:1.17.8=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath net.java.dev.jna:jna:5.18.1=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath @@ -76,19 +75,19 @@ org.apache.commons:commons-text:1.15.0=spotbugs org.apache.commons:commons-text:1.3=checkstyle org.apache.httpcomponents:httpclient:4.5.13=checkstyle org.apache.httpcomponents:httpcore:4.4.16=checkstyle -org.apache.logging.log4j:log4j-api:2.25.2=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.apache.logging.log4j:log4j-core:2.25.2=spotbugs -org.apache.logging.log4j:log4j-to-slf4j:2.25.2=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.apache.logging.log4j:log4j-api:2.25.5=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.apache.logging.log4j:log4j-core:2.25.5=spotbugs +org.apache.logging.log4j:log4j-to-slf4j:2.25.5=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath org.apache.maven.doxia:doxia-core:1.12.0=checkstyle org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle -org.apache.tomcat.embed:tomcat-embed-core:11.0.14=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.apache.tomcat.embed:tomcat-embed-el:11.0.14=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-core:11.0.24=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-el:11.0.24=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-websocket:11.0.24=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath org.apache.xbean:xbean-reflect:3.7=checkstyle org.apiguardian:apiguardian-api:1.1.2=mongoPerformanceTestCompileClasspath,testCompileClasspath,testkitCompileClasspath -org.assertj:assertj-core:3.27.6=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.assertj:assertj-core:3.27.7=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath org.awaitility:awaitility:4.3.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle @@ -99,24 +98,24 @@ org.hamcrest:hamcrest:3.0=mongoPerformanceTestCompileClasspath,mongoPerformanceT org.hdrhistogram:HdrHistogram:2.2.2=mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath org.javassist:javassist:3.28.0-GA=checkstyle org.jetbrains:annotations:17.0.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,mongoPerformanceTestAnnotationProcessor,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath,testkitAnnotationProcessor,testkitCompileClasspath,testkitRuntimeClasspath -org.junit.jupiter:junit-jupiter-api:6.0.1=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.junit.jupiter:junit-jupiter-engine:6.0.1=mongoPerformanceTestRuntimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath -org.junit.jupiter:junit-jupiter-params:6.0.1=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.junit.jupiter:junit-jupiter:6.0.1=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.junit.platform:junit-platform-commons:6.0.1=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.junit.platform:junit-platform-engine:6.0.1=mongoPerformanceTestRuntimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath -org.junit.platform:junit-platform-launcher:6.0.1=mongoPerformanceTestRuntimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath -org.junit:junit-bom:6.0.1=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.jspecify:jspecify:1.0.1=annotationProcessor,checkstyle,compileClasspath,mongoPerformanceTestAnnotationProcessor,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath,testkitAnnotationProcessor,testkitCompileClasspath,testkitRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:6.0.3=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.3=mongoPerformanceTestRuntimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.3=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.3=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.3=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.3=mongoPerformanceTestRuntimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.3=mongoPerformanceTestRuntimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath +org.junit:junit-bom:6.0.3=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath org.junit:junit-bom:6.1.0=spotbugs org.latencyutils:LatencyUtils:2.0.3=mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath org.mockito:mockito-core:5.20.0=mockitoAgent,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath org.mockito:mockito-junit-jupiter:5.20.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.mongodb:bson-record-codec:5.6.1=mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath -org.mongodb:bson:5.6.1=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.mongodb:mongodb-driver-core:5.6.1=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.mongodb:mongodb-driver-reactivestreams:5.6.1=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.mongodb:mongodb-driver-sync:5.6.1=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.mongodb:bson-record-codec:5.6.5=mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath +org.mongodb:bson:5.6.5=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.mongodb:mongodb-driver-core:5.6.5=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.mongodb:mongodb-driver-reactivestreams:5.6.5=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.mongodb:mongodb-driver-sync:5.6.5=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath org.objenesis:objenesis:3.3=mongoPerformanceTestRuntimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath org.opentest4j:opentest4j:1.3.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath org.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,mongoPerformanceTestCompileClasspath,testCompileClasspath,testkitCompileClasspath @@ -134,61 +133,60 @@ org.reactivestreams:reactive-streams:1.0.4=compileClasspath,mongoPerformanceTest org.reflections:reflections:0.10.2=checkstyle org.rnorth.duct-tape:duct-tape:1.0.8=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath org.skyscreamer:jsonassert:1.5.3=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.slf4j:slf4j-api:2.0.17=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j -org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.boot:spring-boot-configuration-processor:4.0.0=annotationProcessor -org.springframework.boot:spring-boot-data-commons:4.0.0=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.boot:spring-boot-data-mongodb:4.0.0=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.boot:spring-boot-http-client:4.0.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.boot:spring-boot-http-converter:4.0.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.boot:spring-boot-jackson:4.0.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.boot:spring-boot-mongodb:4.0.0=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.boot:spring-boot-persistence:4.0.0=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.boot:spring-boot-reactor:4.0.0=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.boot:spring-boot-restclient:4.0.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.boot:spring-boot-resttestclient:4.0.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.boot:spring-boot-servlet:4.0.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.boot:spring-boot-starter-data-mongodb-reactive:4.0.0=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.boot:spring-boot-starter-data-mongodb:4.0.0=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.boot:spring-boot-starter-jackson:4.0.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.boot:spring-boot-starter-mongodb:4.0.0=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.boot:spring-boot-starter-test:4.0.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.boot:spring-boot-starter-tomcat:4.0.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.boot:spring-boot-starter-webmvc:4.0.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.boot:spring-boot-starter:4.0.0=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.boot:spring-boot-test:4.0.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.boot:spring-boot-tomcat:4.0.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.boot:spring-boot-transaction:4.0.0=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.boot:spring-boot-web-server:4.0.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.boot:spring-boot-webmvc-test:4.0.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.boot:spring-boot-webmvc:4.0.0=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.boot:spring-boot:4.0.0=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.data:spring-data-commons:4.0.0=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework.data:spring-data-mongodb:5.0.0=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework:spring-aop:7.0.1=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework:spring-beans:7.0.1=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework:spring-context:7.0.1=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework:spring-core:7.0.1=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework:spring-expression:7.0.1=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework:spring-test:7.0.1=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework:spring-tx:7.0.1=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework:spring-web:7.0.1=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.springframework:spring-webmvc:7.0.1=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.testcontainers:testcontainers-junit-jupiter:2.0.2=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.testcontainers:testcontainers-mongodb:2.0.2=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.testcontainers:testcontainers-toxiproxy:2.0.2=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -org.testcontainers:testcontainers:2.0.2=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.slf4j:jul-to-slf4j:2.0.18=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.slf4j:slf4j-api:2.0.18=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.18=checkstyle +org.springframework.boot:spring-boot-autoconfigure:4.0.8=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-configuration-processor:4.0.8=annotationProcessor +org.springframework.boot:spring-boot-data-commons:4.0.8=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-data-mongodb:4.0.8=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-http-converter:4.0.8=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-jackson:4.0.8=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-mongodb:4.0.8=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-persistence:4.0.8=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-reactor:4.0.8=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-resttestclient:4.0.8=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-servlet:4.0.8=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-starter-data-mongodb-reactive:4.0.8=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-starter-data-mongodb:4.0.8=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-starter-jackson-test:4.0.8=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-starter-jackson:4.0.8=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-starter-logging:4.0.8=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-starter-mongodb:4.0.8=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-starter-test:4.0.8=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.8=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat:4.0.8=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-starter-webmvc-test:4.0.8=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-starter-webmvc:4.0.8=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-starter:4.0.8=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-test-autoconfigure:4.0.8=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-test:4.0.8=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-tomcat:4.0.8=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-transaction:4.0.8=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-web-server:4.0.8=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-webmvc-test:4.0.8=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot-webmvc:4.0.8=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.boot:spring-boot:4.0.8=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.data:spring-data-commons:4.0.7=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework.data:spring-data-mongodb:5.0.7=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework:spring-aop:7.0.9=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework:spring-beans:7.0.9=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework:spring-context:7.0.9=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework:spring-core:7.0.9=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework:spring-expression:7.0.9=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework:spring-test:7.0.9=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework:spring-tx:7.0.9=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework:spring-web:7.0.9=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.springframework:spring-webmvc:7.0.9=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.testcontainers:testcontainers-junit-jupiter:2.0.5=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.testcontainers:testcontainers-mongodb:2.0.5=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.testcontainers:testcontainers-toxiproxy:2.0.5=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +org.testcontainers:testcontainers:2.0.5=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs org.xmlunit:xmlunit-core:2.10.4=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath org.yaml:snakeyaml:2.5=compileClasspath,mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -tools.jackson.core:jackson-core:3.0.2=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -tools.jackson.core:jackson-databind:3.0.2=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath -tools.jackson:jackson-bom:3.0.2=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +tools.jackson.core:jackson-core:3.1.5=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +tools.jackson.core:jackson-databind:3.1.5=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath +tools.jackson:jackson-bom:3.1.5=mongoPerformanceTestCompileClasspath,mongoPerformanceTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath empty= diff --git a/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/failover/MongoProxiedReplicaSetNode.java b/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/failover/MongoProxiedReplicaSetNode.java index f9df0c7c..b6d323c6 100644 --- a/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/failover/MongoProxiedReplicaSetNode.java +++ b/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/failover/MongoProxiedReplicaSetNode.java @@ -32,6 +32,9 @@ public final class MongoProxiedReplicaSetNode implements AutoCloseable { private ToxiproxyMongoNetworkFaultController faults; + // Scope-based leak analysis cannot see the owner: the node owns its container and closes it with + // the replica set. + @SuppressWarnings("resource") private MongoProxiedReplicaSetNode(String image) { this.mongod = new GenericContainer<>(DockerImageName.parse(image)) diff --git a/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/failover/MongoThreeNodeReplicaSet.java b/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/failover/MongoThreeNodeReplicaSet.java index f7f8f4b4..77b587ce 100644 --- a/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/failover/MongoThreeNodeReplicaSet.java +++ b/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/failover/MongoThreeNodeReplicaSet.java @@ -41,6 +41,9 @@ public final class MongoThreeNodeReplicaSet implements AutoCloseable, MongoPrima private String stoppedPrimaryAlias = ""; + // Scope-based leak analysis cannot see the owner: the replica set owns every node it starts and + // closes them together. + @SuppressWarnings("resource") private MongoThreeNodeReplicaSet(String image) { for (int index = 0; index < 3; index++) { nodes.add( diff --git a/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/failover/ToxiproxyMongoNetworkFaultController.java b/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/failover/ToxiproxyMongoNetworkFaultController.java index 510e40f0..5fa768ac 100644 --- a/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/failover/ToxiproxyMongoNetworkFaultController.java +++ b/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/failover/ToxiproxyMongoNetworkFaultController.java @@ -55,6 +55,9 @@ public final class ToxiproxyMongoNetworkFaultController * @param network the network the MongoDB node is attached to * @param upstreamAlias the node's network alias, e.g. {@code mongo-0} */ + // Scope-based leak analysis cannot see the owner: the controller owns the proxy container for its + // own lifetime. + @SuppressWarnings("resource") public static ToxiproxyMongoNetworkFaultController inFrontOf( Network network, String upstreamAlias) { Objects.requireNonNull(network, "network"); diff --git a/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/rs/MongoAuthenticatedReplicaSetContainer.java b/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/rs/MongoAuthenticatedReplicaSetContainer.java index a300f2e7..ea2e9f9b 100644 --- a/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/rs/MongoAuthenticatedReplicaSetContainer.java +++ b/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/rs/MongoAuthenticatedReplicaSetContainer.java @@ -46,6 +46,9 @@ public final class MongoAuthenticatedReplicaSetContainer implements AutoCloseabl private final GenericContainer container; + // Scope-based leak analysis cannot see the owner: the container is this wrapper's field; the + // wrapper closes it. + @SuppressWarnings("resource") private MongoAuthenticatedReplicaSetContainer(String image) { this.container = new GenericContainer<>(DockerImageName.parse(image)) diff --git a/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/rs/MongoSingleReplicaSetContainer.java b/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/rs/MongoSingleReplicaSetContainer.java index 2407c81a..6d3e3230 100644 --- a/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/rs/MongoSingleReplicaSetContainer.java +++ b/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/rs/MongoSingleReplicaSetContainer.java @@ -24,6 +24,9 @@ public final class MongoSingleReplicaSetContainer implements AutoCloseable { private final MongoDBContainer container; + // Scope-based leak analysis cannot see the owner: the container is this wrapper's field; the + // wrapper closes it. + @SuppressWarnings("resource") private MongoSingleReplicaSetContainer(String image) { // withReplicaSet() is not the default: MongoDBContainer starts a standalone otherwise, and a // standalone silently refuses transactions, retryable writes and change streams. A fixture diff --git a/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/rs/MongoTlsReplicaSetContainer.java b/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/rs/MongoTlsReplicaSetContainer.java index fccaef08..3402a220 100644 --- a/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/rs/MongoTlsReplicaSetContainer.java +++ b/src/adapter/outbound/persistence-mongo/src/testkit/java/dev/caskeleton/adapter/outbound/mongo/testkit/rs/MongoTlsReplicaSetContainer.java @@ -30,6 +30,9 @@ public final class MongoTlsReplicaSetContainer implements AutoCloseable { private final GenericContainer container; + // Scope-based leak analysis cannot see the owner: the container is this wrapper's field; the + // wrapper closes it. + @SuppressWarnings("resource") private MongoTlsReplicaSetContainer( String image, String subjectAlternativeName, int validForSeconds) { this.container = diff --git a/src/adapter/outbound/support/gradle.lockfile b/src/adapter/outbound/support/gradle.lockfile index 8ad21fee..77fd02a7 100644 --- a/src/adapter/outbound/support/gradle.lockfile +++ b/src/adapter/outbound/support/gradle.lockfile @@ -2,19 +2,18 @@ # Manual edits can break the build and are not advised. # This file is expected to be part of source control. biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=testCompileClasspath -ch.qos.logback:logback-classic:1.5.21=testCompileClasspath,testRuntimeClasspath -ch.qos.logback:logback-core:1.5.21=testCompileClasspath,testRuntimeClasspath -com.fasterxml.jackson.core:jackson-annotations:2.20=testCompileClasspath,testRuntimeClasspath -com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor +ch.qos.logback:logback-classic:1.5.38=testCompileClasspath,testRuntimeClasspath +ch.qos.logback:logback-core:1.5.38=testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-annotations:2.21=testCompileClasspath,testRuntimeClasspath +com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,testAnnotationProcessor com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs -com.github.spotbugs:spotbugs-annotations:4.8.6=testCompileClasspath com.github.spotbugs:spotbugs:4.10.2=spotbugs com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor -com.google.code.findbugs:jsr305:3.0.2=checkstyle,spotbugs,testCompileClasspath +com.google.code.findbugs:jsr305:3.0.2=checkstyle,spotbugs com.google.code.gson:gson:2.13.2=spotbugs com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor com.google.errorprone:error_prone_annotations:2.38.0=testCompileClasspath @@ -31,23 +30,23 @@ com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=anno com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins -com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath +com.jayway.jsonpath:json-path:2.10.0=testCompileClasspath,testRuntimeClasspath com.puppycrawl.tools:checkstyle:13.5.0=checkstyle com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath commons-beanutils:commons-beanutils:1.11.0=checkstyle commons-collections:commons-collections:3.2.2=checkstyle commons-io:commons-io:2.21.0=spotbugs -commons-logging:commons-logging:1.3.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +commons-logging:commons-logging:1.3.6=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath info.picocli:picocli:4.7.7=checkstyle io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor -io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-commons:1.16.7=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-observation:1.16.7=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath jakarta.activation:jakarta.activation-api:2.1.4=testCompileClasspath,testRuntimeClasspath jakarta.annotation:jakarta.annotation-api:3.0.0=testCompileClasspath,testRuntimeClasspath -jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=testCompileClasspath,testRuntimeClasspath +jakarta.xml.bind:jakarta.xml.bind-api:4.0.5=testCompileClasspath,testRuntimeClasspath javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor -jaxen:jaxen:2.0.0=spotbugs +jaxen:jaxen:2.0.6=spotbugs net.bytebuddy:byte-buddy-agent:1.17.8=testCompileClasspath,testRuntimeClasspath net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath net.minidev:accessors-smart:2.6.0=testCompileClasspath,testRuntimeClasspath @@ -60,19 +59,19 @@ org.apache.commons:commons-text:1.15.0=spotbugs org.apache.commons:commons-text:1.3=checkstyle org.apache.httpcomponents:httpclient:4.5.13=checkstyle org.apache.httpcomponents:httpcore:4.4.16=checkstyle -org.apache.logging.log4j:log4j-api:2.25.2=spotbugs,testCompileClasspath,testRuntimeClasspath -org.apache.logging.log4j:log4j-core:2.25.2=spotbugs -org.apache.logging.log4j:log4j-to-slf4j:2.25.2=testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-api:2.25.5=spotbugs,testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-core:2.25.5=spotbugs +org.apache.logging.log4j:log4j-to-slf4j:2.25.5=testCompileClasspath,testRuntimeClasspath org.apache.maven.doxia:doxia-core:1.12.0=checkstyle org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle -org.apache.tomcat.embed:tomcat-embed-core:11.0.14=testCompileClasspath,testRuntimeClasspath -org.apache.tomcat.embed:tomcat-embed-el:11.0.14=testCompileClasspath,testRuntimeClasspath -org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-core:11.0.24=testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-el:11.0.24=testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-websocket:11.0.24=testCompileClasspath,testRuntimeClasspath org.apache.xbean:xbean-reflect:3.7=checkstyle org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath -org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath +org.assertj:assertj-core:3.27.7=testCompileClasspath,testRuntimeClasspath org.awaitility:awaitility:4.3.0=testCompileClasspath,testRuntimeClasspath org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle @@ -81,15 +80,15 @@ org.codehaus.plexus:plexus-utils:3.3.0=checkstyle org.dom4j:dom4j:2.2.0=spotbugs org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath org.javassist:javassist:3.28.0-GA=checkstyle -org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath -org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-engine:6.0.1=testRuntimeClasspath -org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath -org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath +org.jspecify:jspecify:1.0.1=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.3=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.3=testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.3=testRuntimeClasspath +org.junit:junit-bom:6.0.3=testCompileClasspath,testRuntimeClasspath org.junit:junit-bom:6.1.0=spotbugs org.mockito:mockito-core:5.20.0=mockitoAgent,testCompileClasspath,testRuntimeClasspath org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath @@ -108,44 +107,43 @@ org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor org.reflections:reflections:0.10.2=checkstyle org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath -org.slf4j:jul-to-slf4j:2.0.17=testCompileClasspath,testRuntimeClasspath -org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath -org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j -org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-http-converter:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-restclient:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-servlet:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-logging:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-web-server:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-aop:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-beans:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-context:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-core:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-expression:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-test:7.0.1=testCompileClasspath,testRuntimeClasspath -org.springframework:spring-web:7.0.1=testCompileClasspath,testRuntimeClasspath -org.springframework:spring-webmvc:7.0.1=testCompileClasspath,testRuntimeClasspath +org.slf4j:jul-to-slf4j:2.0.18=testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-api:2.0.18=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.18=checkstyle +org.springframework.boot:spring-boot-autoconfigure:4.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-http-converter:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-jackson:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-resttestclient:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-servlet:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-jackson-test:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-jackson:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-logging:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-test:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-webmvc-test:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-webmvc:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-test-autoconfigure:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-test:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-tomcat:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-web-server:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-webmvc-test:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-webmvc:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot:4.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-aop:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-beans:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-context:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-core:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-expression:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-test:7.0.9=testCompileClasspath,testRuntimeClasspath +org.springframework:spring-web:7.0.9=testCompileClasspath,testRuntimeClasspath +org.springframework:spring-webmvc:7.0.9=testCompileClasspath,testRuntimeClasspath org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath org.yaml:snakeyaml:2.5=testCompileClasspath,testRuntimeClasspath -tools.jackson.core:jackson-core:3.0.2=testCompileClasspath,testRuntimeClasspath -tools.jackson.core:jackson-databind:3.0.2=testCompileClasspath,testRuntimeClasspath -tools.jackson:jackson-bom:3.0.2=testCompileClasspath,testRuntimeClasspath +tools.jackson.core:jackson-core:3.1.5=testCompileClasspath,testRuntimeClasspath +tools.jackson.core:jackson-databind:3.1.5=testCompileClasspath,testRuntimeClasspath +tools.jackson:jackson-bom:3.1.5=testCompileClasspath,testRuntimeClasspath empty= diff --git a/src/app-bootstrap/build.gradle b/src/app-bootstrap/build.gradle index 68849241..6d70ffb7 100644 --- a/src/app-bootstrap/build.gradle +++ b/src/app-bootstrap/build.gradle @@ -60,6 +60,7 @@ dependencies { // ArchUnit assertions, and the composition root is the one place that can see every runtime // leaf at once — which is what makes it the right consumer and the wrong shipper. testImplementation(project(path: ':adapter:outbound:persistence-jpa', configuration: 'jpaTestkit')) + testImplementation(project(path: ':adapter:inbound:web', configuration: 'webTestkit')) // Shipped so an operator can enable it with one environment variable. An adapter that is // absent is not the same contract as one that is off: absence cannot be reversed at deploy time, // and it hides every gating defect, because code that is not there holds no beans whatever its diff --git a/src/app-bootstrap/gradle.lockfile b/src/app-bootstrap/gradle.lockfile index ba8ffb3d..aebfadb4 100644 --- a/src/app-bootstrap/gradle.lockfile +++ b/src/app-bootstrap/gradle.lockfile @@ -2,40 +2,38 @@ # Manual edits can break the build and are not advised. # This file is expected to be part of source control. aopalliance:aopalliance:1.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +at.yawk.lz4:lz4-java:1.10.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,sampleOffTestCompileClasspath,testCompileClasspath -ch.qos.logback:logback-classic:1.5.21=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath ch.qos.logback:logback-classic:1.5.34=sampleFixture -ch.qos.logback:logback-core:1.5.21=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +ch.qos.logback:logback-classic:1.5.38=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath ch.qos.logback:logback-core:1.5.34=sampleFixture +ch.qos.logback:logback-core:1.5.38=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.approvaltests:approvaltests-util:31.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.approvaltests:approvaltests:31.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.ethlo.time:itu:1.14.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -com.fasterxml.jackson.core:jackson-annotations:2.20=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -com.fasterxml.jackson.core:jackson-annotations:2.21=sampleFixture -com.fasterxml.jackson.core:jackson-core:2.20.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-annotations:2.21=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.fasterxml.jackson.core:jackson-core:2.21.4=sampleFixture -com.fasterxml.jackson.core:jackson-databind:2.20.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-core:2.21.5=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.fasterxml.jackson.core:jackson-databind:2.21.4=sampleFixture +com.fasterxml.jackson.core:jackson-databind:2.21.5=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.fasterxml.jackson.dataformat:jackson-dataformat-toml:2.21.4=sampleFixture -com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.20.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.21.4=sampleFixture +com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.21.5=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath com.fasterxml.jackson.datatype:jackson-datatype-jdk8:2.21.4=sampleFixture -com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.20.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.21.4=sampleFixture +com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.21.5=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath com.fasterxml.jackson.module:jackson-module-parameter-names:2.21.4=sampleFixture -com.fasterxml.jackson:jackson-bom:2.20.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.fasterxml.jackson:jackson-bom:2.21.4=sampleFixture -com.fasterxml:classmate:1.7.1=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -com.fasterxml:classmate:1.7.3=sampleFixture -com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor -com.github.docker-java:docker-java-api:3.7.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -com.github.docker-java:docker-java-transport-zerodep:3.7.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -com.github.docker-java:docker-java-transport:3.7.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson:jackson-bom:2.21.5=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml:classmate:1.7.3=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor +com.github.docker-java:docker-java-api:3.7.1=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.github.docker-java:docker-java-transport-zerodep:3.7.1=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.github.docker-java:docker-java-transport:3.7.1=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.github.f4b6a3:uuid-creator:6.1.1=sampleFixture,testRuntimeClasspath com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor com.github.luben:zstd-jni:1.5.6-10=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs -com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,sampleOffTestCompileClasspath,testCompileClasspath com.github.spotbugs:spotbugs:4.10.2=spotbugs com.github.stephenc.jcip:jcip-annotations:1.0-1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath com.google.android:annotations:4.1.1.4=conditionalTransportTestRuntimeClasspath @@ -43,7 +41,7 @@ com.google.api.grpc:proto-google-common-protos:2.41.0=conditionalTransportTestRu com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor com.google.auto:auto-common:1.2.2=annotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor -com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,conditionalTransportTestRuntimeClasspath,sampleOffTestCompileClasspath,spotbugs,testCompileClasspath +com.google.code.findbugs:jsr305:3.0.2=checkstyle,conditionalTransportTestRuntimeClasspath,spotbugs com.google.code.gson:gson:2.13.2=conditionalTransportTestRuntimeClasspath,spotbugs com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,sampleOffTestCompileClasspath,testCompileClasspath @@ -68,7 +66,7 @@ com.graphql-java:graphql-java:25.0=conditionalTransportTestRuntimeClasspath,prod com.graphql-java:java-dataloader:6.0.0=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath com.h2database:h2:2.4.240=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins -com.jayway.jsonpath:json-path:2.9.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.jayway.jsonpath:json-path:2.10.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.networknt:json-schema-validator:3.0.2=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath com.nimbusds:content-type:2.3=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.nimbusds:lang-tag:1.7=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath @@ -98,7 +96,7 @@ commons-codec:commons-codec:1.19.0=sampleOffTestCompileClasspath,sampleOffTestRu commons-collections:commons-collections:3.2.2=checkstyle commons-io:commons-io:2.20.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath commons-io:commons-io:2.21.0=spotbugs -commons-logging:commons-logging:1.3.5=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +commons-logging:commons-logging:1.3.6=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath info.picocli:picocli:4.7.7=checkstyle io.cloudevents:cloudevents-api:4.0.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath io.cloudevents:cloudevents-core:4.0.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath @@ -121,23 +119,23 @@ io.grpc:grpc-protobuf:1.68.1=conditionalTransportTestRuntimeClasspath io.grpc:grpc-services:1.68.1=conditionalTransportTestRuntimeClasspath io.grpc:grpc-stub:1.68.1=conditionalTransportTestRuntimeClasspath io.grpc:grpc-util:1.68.1=conditionalTransportTestRuntimeClasspath -io.lettuce:lettuce-core:6.8.1.RELEASE=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.lettuce:lettuce-core:6.8.2.RELEASE=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath io.micrometer:context-propagation:1.1.4=sampleFixture -io.micrometer:context-propagation:1.2.0=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:context-propagation:1.2.1=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.micrometer:micrometer-commons:1.15.12=sampleFixture -io.micrometer:micrometer-commons:1.16.0=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-commons:1.16.7=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.micrometer:micrometer-core:1.15.12=sampleFixture -io.micrometer:micrometer-core:1.16.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-core:1.16.7=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.micrometer:micrometer-jakarta9:1.15.12=sampleFixture -io.micrometer:micrometer-jakarta9:1.16.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-jakarta9:1.16.7=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.micrometer:micrometer-observation:1.15.12=sampleFixture -io.micrometer:micrometer-observation:1.16.0=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-observation:1.16.7=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.micrometer:micrometer-registry-prometheus:1.15.12=sampleFixture -io.micrometer:micrometer-registry-prometheus:1.16.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-registry-prometheus:1.16.7=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.micrometer:micrometer-tracing-bridge-otel:1.5.12=sampleFixture -io.micrometer:micrometer-tracing-bridge-otel:1.6.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-tracing-bridge-otel:1.6.7=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.micrometer:micrometer-tracing:1.5.12=sampleFixture -io.micrometer:micrometer-tracing:1.6.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-tracing:1.6.7=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.netty:netty-buffer:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath io.netty:netty-codec-base:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath io.netty:netty-codec-classes-quic:4.2.17.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath @@ -192,10 +190,10 @@ io.opentelemetry:opentelemetry-sdk-trace:1.55.0=compileClasspath,productionRunti io.opentelemetry:opentelemetry-sdk:1.49.0=sampleFixture io.opentelemetry:opentelemetry-sdk:1.55.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.perfmark:perfmark-api:0.27.0=conditionalTransportTestRuntimeClasspath -io.projectreactor.netty:reactor-netty-core:1.3.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -io.projectreactor.netty:reactor-netty-http:1.3.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.projectreactor.netty:reactor-netty-core:1.3.7=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +io.projectreactor.netty:reactor-netty-http:1.3.7=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath io.projectreactor:reactor-core:3.7.19=sampleFixture -io.projectreactor:reactor-core:3.8.0=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +io.projectreactor:reactor-core:3.8.7=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.prometheus:prometheus-metrics-config:1.3.10=sampleFixture io.prometheus:prometheus-metrics-config:1.4.3=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath io.prometheus:prometheus-metrics-core:1.3.10=sampleFixture @@ -227,10 +225,9 @@ jakarta.validation:jakarta.validation-api:3.0.2=sampleFixture jakarta.validation:jakarta.validation-api:3.1.1=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath jakarta.websocket:jakarta.websocket-api:2.2.0=sampleOffTestCompileClasspath,testCompileClasspath jakarta.ws.rs:jakarta.ws.rs-api:4.0.0=sampleOffTestCompileClasspath,testCompileClasspath -jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -jakarta.xml.bind:jakarta.xml.bind-api:4.0.5=sampleFixture +jakarta.xml.bind:jakarta.xml.bind-api:4.0.5=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath javax.inject:javax.inject:1=annotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor -jaxen:jaxen:2.0.0=spotbugs +jaxen:jaxen:2.0.6=spotbugs me.paulschwarz:spring-dotenv:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath net.bytebuddy:byte-buddy-agent:1.17.8=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath net.bytebuddy:byte-buddy:1.17.8=conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath @@ -246,38 +243,37 @@ org.apache.commons:commons-compress:1.28.0=sampleOffTestCompileClasspath,sampleO org.apache.commons:commons-lang3:3.20.0=checkstyle,productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath org.apache.commons:commons-text:1.15.0=spotbugs org.apache.commons:commons-text:1.3=checkstyle -org.apache.groovy:groovy-bom:5.0.2=sampleOffTestCompileClasspath,testCompileClasspath -org.apache.groovy:groovy:5.0.2=sampleOffTestCompileClasspath,testCompileClasspath -org.apache.httpcomponents.client5:httpclient5:5.5.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.apache.groovy:groovy-bom:5.0.8=sampleOffTestCompileClasspath,testCompileClasspath +org.apache.groovy:groovy:5.0.8=sampleOffTestCompileClasspath,testCompileClasspath +org.apache.httpcomponents.client5:httpclient5:5.5.2=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.apache.httpcomponents.core5:httpcore5-h2:5.3.6=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.apache.httpcomponents.core5:httpcore5:5.3.6=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.apache.httpcomponents:httpclient:4.5.13=checkstyle,testRuntimeClasspath org.apache.httpcomponents:httpcore:4.4.16=checkstyle,testRuntimeClasspath -org.apache.kafka:kafka-clients:4.1.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.kafka:kafka-clients:4.1.2=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.apache.logging.log4j:log4j-api:2.24.3=sampleFixture -org.apache.logging.log4j:log4j-api:2.25.2=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath -org.apache.logging.log4j:log4j-core:2.25.2=spotbugs +org.apache.logging.log4j:log4j-api:2.25.5=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-core:2.25.5=spotbugs org.apache.logging.log4j:log4j-to-slf4j:2.24.3=sampleFixture -org.apache.logging.log4j:log4j-to-slf4j:2.25.2=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-to-slf4j:2.25.5=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.apache.maven.doxia:doxia-core:1.12.0=checkstyle org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle org.apache.tomcat.embed:tomcat-embed-core:10.1.55=sampleFixture -org.apache.tomcat.embed:tomcat-embed-core:11.0.14=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-core:11.0.24=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.apache.tomcat.embed:tomcat-embed-el:10.1.55=sampleFixture -org.apache.tomcat.embed:tomcat-embed-el:11.0.14=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-el:11.0.24=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.apache.tomcat.embed:tomcat-embed-websocket:10.1.55=sampleFixture -org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-websocket:11.0.24=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.apache.xbean:xbean-reflect:3.7=checkstyle org.apiguardian:apiguardian-api:1.1.2=conditionalTransportTestCompileClasspath,functionalTestCompileClasspath,sampleOffTestCompileClasspath,testCompileClasspath -org.aspectj:aspectjweaver:1.9.25=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.aspectj:aspectjweaver:1.9.25.1=sampleFixture -org.assertj:assertj-core:3.27.6=conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.aspectj:aspectjweaver:1.9.25.1=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.assertj:assertj-core:3.27.7=conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.attoparser:attoparser:2.0.7.RELEASE=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.awaitility:awaitility:4.3.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.checkerframework:checker-qual:3.42.0=conditionalTransportTestRuntimeClasspath -org.checkerframework:checker-qual:3.49.5=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.checkerframework:checker-qual:3.55.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.codehaus.mojo:animal-sniffer-annotations:1.24=conditionalTransportTestRuntimeClasspath org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle @@ -286,35 +282,31 @@ org.codehaus.plexus:plexus-utils:3.3.0=checkstyle org.dom4j:dom4j:2.2.0=spotbugs org.eclipse.angus:angus-activation:2.0.3=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.eclipse.angus:angus-mail:2.0.5=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -org.eclipse.jetty.compression:jetty-compression-common:12.1.4=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -org.eclipse.jetty.compression:jetty-compression-gzip:12.1.4=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -org.eclipse.jetty:jetty-alpn-client:12.1.4=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -org.eclipse.jetty:jetty-client:12.1.4=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -org.eclipse.jetty:jetty-http:12.1.4=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -org.eclipse.jetty:jetty-io:12.1.4=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -org.eclipse.jetty:jetty-util:12.1.4=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.eclipse.jetty.compression:jetty-compression-common:12.1.12=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.eclipse.jetty.compression:jetty-compression-gzip:12.1.12=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.eclipse.jetty:jetty-alpn-client:12.1.12=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.eclipse.jetty:jetty-client:12.1.12=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.eclipse.jetty:jetty-http:12.1.12=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.eclipse.jetty:jetty-io:12.1.12=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.eclipse.jetty:jetty-util:12.1.12=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.flywaydb:flyway-core:11.14.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.flywaydb:flyway-core:11.7.2=sampleFixture org.flywaydb:flyway-database-postgresql:11.14.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.flywaydb:flyway-database-postgresql:11.7.2=sampleFixture -org.glassfish.jaxb:jaxb-core:4.0.6=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -org.glassfish.jaxb:jaxb-core:4.0.9=sampleFixture -org.glassfish.jaxb:jaxb-runtime:4.0.6=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -org.glassfish.jaxb:jaxb-runtime:4.0.9=sampleFixture -org.glassfish.jaxb:txw2:4.0.6=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -org.glassfish.jaxb:txw2:4.0.9=sampleFixture +org.glassfish.jaxb:jaxb-core:4.0.9=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.glassfish.jaxb:jaxb-runtime:4.0.9=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.glassfish.jaxb:txw2:4.0.9=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.hamcrest:hamcrest:3.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.hdrhistogram:HdrHistogram:2.2.2=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.hibernate.common:hibernate-commons-annotations:7.0.3.Final=sampleFixture org.hibernate.models:hibernate-models:1.0.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.hibernate.orm:hibernate-core:6.6.53.Final=sampleFixture -org.hibernate.orm:hibernate-core:7.1.8.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.hibernate.orm:hibernate-core:7.2.24.Final=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.hibernate.validator:hibernate-validator:8.0.3.Final=sampleFixture org.hibernate.validator:hibernate-validator:9.0.1.Final=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.javassist:javassist:3.28.0-GA=checkstyle org.javassist:javassist:3.29.0-GA=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -org.jboss.logging:jboss-logging:3.6.1.Final=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.jboss.logging:jboss-logging:3.6.3.Final=sampleFixture +org.jboss.logging:jboss-logging:3.6.3.Final=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.jetbrains.kotlin:kotlin-stdlib-common:1.9.25=sampleFixture org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.25=sampleFixture org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.25=sampleFixture @@ -322,25 +314,24 @@ org.jetbrains.kotlin:kotlin-stdlib:1.9.25=sampleFixture org.jetbrains.kotlin:kotlin-stdlib:2.2.21=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.jetbrains:annotations:13.0=productionRuntimeClasspath,runtimeClasspath,sampleFixture org.jetbrains:annotations:17.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,conditionalTransportTestAnnotationProcessor,conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestAnnotationProcessor,functionalTestCompileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestAnnotationProcessor,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-api:6.0.1=conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-engine:6.0.1=conditionalTransportTestRuntimeClasspath,functionalTestRuntimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-params:6.0.1=conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter:6.0.1=conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-commons:6.0.1=conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-engine:6.0.1=conditionalTransportTestRuntimeClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-launcher:6.0.1=conditionalTransportTestRuntimeClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-testkit:6.0.1=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.junit:junit-bom:6.0.1=conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.jspecify:jspecify:1.0.1=annotationProcessor,checkstyle,compileClasspath,conditionalTransportTestAnnotationProcessor,conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestAnnotationProcessor,functionalTestCompileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestAnnotationProcessor,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:6.0.3=conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.3=conditionalTransportTestRuntimeClasspath,functionalTestRuntimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.3=conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.3=conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.3=conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.3=conditionalTransportTestRuntimeClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.3=conditionalTransportTestRuntimeClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-testkit:6.0.3=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.junit:junit-bom:6.0.3=conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.junit:junit-bom:6.1.0=spotbugs org.latencyutils:LatencyUtils:2.0.3=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath -org.lz4:lz4-java:1.8.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.mockito:mockito-core:5.20.0=mockitoAgent,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.mockito:mockito-junit-jupiter:5.20.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.mongodb:bson-record-codec:5.6.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -org.mongodb:bson:5.6.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -org.mongodb:mongodb-driver-core:5.6.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -org.mongodb:mongodb-driver-sync:5.6.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.mongodb:bson-record-codec:5.6.5=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.mongodb:bson:5.6.5=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.mongodb:mongodb-driver-core:5.6.5=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.mongodb:mongodb-driver-sync:5.6.5=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.objenesis:objenesis:3.3=sampleOffTestRuntimeClasspath,testRuntimeClasspath org.openapitools:jackson-databind-nullable:0.2.6=productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.opentest4j:opentest4j:1.3.0=conditionalTransportTestCompileClasspath,conditionalTransportTestRuntimeClasspath,functionalTestCompileClasspath,functionalTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath @@ -356,166 +347,164 @@ org.ow2.asm:asm:9.10.1=spotbugs org.ow2.asm:asm:9.7.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.pcollections:pcollections:4.0.1=annotationProcessor,conditionalTransportTestAnnotationProcessor,functionalTestAnnotationProcessor,sampleOffTestAnnotationProcessor,testAnnotationProcessor org.postgresql:postgresql:42.7.11=sampleFixture -org.postgresql:postgresql:42.7.8=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.postgresql:postgresql:42.7.13=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.reactivestreams:reactive-streams:1.0.4=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.reflections:reflections:0.10.2=checkstyle org.rnorth.duct-tape:duct-tape:1.0.8=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.skyscreamer:jsonassert:1.5.3=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.slf4j:jul-to-slf4j:2.0.18=sampleFixture -org.slf4j:slf4j-api:2.0.17=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath -org.slf4j:slf4j-api:2.0.18=sampleFixture -org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j +org.slf4j:jul-to-slf4j:2.0.18=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-api:2.0.18=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleFixture,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.18=checkstyle org.springdoc:springdoc-openapi-starter-common:2.8.6=sampleFixture org.springdoc:springdoc-openapi-starter-common:3.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.springdoc:springdoc-openapi-starter-webmvc-api:2.8.6=sampleFixture org.springdoc:springdoc-openapi-starter-webmvc-api:3.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -org.springframework.amqp:spring-amqp:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -org.springframework.amqp:spring-rabbit:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.springframework.amqp:spring-amqp:4.0.5=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.springframework.amqp:spring-rabbit:4.0.5=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-actuator-autoconfigure:3.5.16=sampleFixture -org.springframework.boot:spring-boot-actuator-autoconfigure:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-actuator-autoconfigure:4.0.8=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-actuator:3.5.16=sampleFixture -org.springframework.boot:spring-boot-actuator:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-actuator:4.0.8=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-autoconfigure:3.5.16=sampleFixture -org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-configuration-processor:4.0.0=annotationProcessor -org.springframework.boot:spring-boot-data-commons:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-data-jpa-test:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-data-jpa:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-data-mongodb:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-flyway:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-graphql:4.0.0=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-health:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-hibernate:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-http-client:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-http-converter:4.0.0=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-jackson:4.0.0=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-jdbc-test:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-jdbc:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-jpa-test:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-jpa:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-mail:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-micrometer-metrics:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-micrometer-observation:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-mongodb:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-persistence:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-restclient:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-resttestclient:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-security-oauth2-resource-server:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-security:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-servlet:4.0.0=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-sql:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-autoconfigure:4.0.8=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-configuration-processor:4.0.8=annotationProcessor +org.springframework.boot:spring-boot-data-commons:4.0.8=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-data-jpa-test:4.0.8=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-data-jpa:4.0.8=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-data-mongodb:4.0.8=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-flyway:4.0.8=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-graphql:4.0.8=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-health:4.0.8=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-hibernate:4.0.8=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-http-converter:4.0.8=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-jackson:4.0.8=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-jdbc-test:4.0.8=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-jdbc:4.0.8=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-jpa-test:4.0.8=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-jpa:4.0.8=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-mail:4.0.8=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-micrometer-metrics:4.0.8=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-micrometer-observation:4.0.8=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-mongodb:4.0.8=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-persistence:4.0.8=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-reactor:4.0.8=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-resttestclient:4.0.8=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-security-oauth2-resource-server:4.0.8=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-security:4.0.8=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-servlet:4.0.8=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-sql:4.0.8=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-actuator:3.5.16=sampleFixture -org.springframework.boot:spring-boot-starter-actuator:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-actuator:4.0.8=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-data-jpa:3.5.16=sampleFixture -org.springframework.boot:spring-boot-starter-data-jpa:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-data-mongodb:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-flyway:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-graphql:4.0.0=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-jackson:4.0.0=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-data-jpa:4.0.8=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-data-mongodb:4.0.8=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-flyway:4.0.8=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-graphql:4.0.8=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-jackson-test:4.0.8=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-jackson:4.0.8=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-jdbc:3.5.16=sampleFixture -org.springframework.boot:spring-boot-starter-jdbc:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-jdbc:4.0.8=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-json:3.5.16=sampleFixture -org.springframework.boot:spring-boot-starter-json:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-json:4.0.8=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-logging:3.5.16=sampleFixture -org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-mail:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-micrometer-metrics:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-mongodb:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-logging:4.0.8=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-mail:4.0.8=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-micrometer-metrics:4.0.8=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-mongodb:4.0.8=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-oauth2-resource-server:3.5.16=sampleFixture -org.springframework.boot:spring-boot-starter-oauth2-resource-server:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-oauth2-resource-server:4.0.8=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-security:3.5.16=sampleFixture -org.springframework.boot:spring-boot-starter-security:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-test:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-security:4.0.8=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-test:4.0.8=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.8=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-tomcat:3.5.16=sampleFixture -org.springframework.boot:spring-boot-starter-tomcat:4.0.0=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat:4.0.8=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-validation:3.5.16=sampleFixture -org.springframework.boot:spring-boot-starter-validation:4.0.0=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-validation:4.0.8=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.boot:spring-boot-starter-web:3.5.16=sampleFixture -org.springframework.boot:spring-boot-starter-web:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-webmvc:4.0.0=conditionalTransportTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-websocket:4.0.0=conditionalTransportTestRuntimeClasspath +org.springframework.boot:spring-boot-starter-web:4.0.8=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-webmvc-test:4.0.8=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-webmvc:4.0.8=conditionalTransportTestRuntimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-websocket:4.0.8=conditionalTransportTestRuntimeClasspath org.springframework.boot:spring-boot-starter:3.5.16=sampleFixture -org.springframework.boot:spring-boot-starter:4.0.0=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-test:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-tomcat:4.0.0=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-transaction:4.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-validation:4.0.0=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-web-server:4.0.0=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-webmvc-test:4.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-webmvc:4.0.0=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-websocket:4.0.0=conditionalTransportTestRuntimeClasspath +org.springframework.boot:spring-boot-starter:4.0.8=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-test-autoconfigure:4.0.8=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-test:4.0.8=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-tomcat:4.0.8=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-transaction:4.0.8=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-validation:4.0.8=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-web-server:4.0.8=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-webmvc-test:4.0.8=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-webmvc:4.0.8=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-websocket:4.0.8=conditionalTransportTestRuntimeClasspath org.springframework.boot:spring-boot:3.5.16=sampleFixture -org.springframework.boot:spring-boot:4.0.0=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot:4.0.8=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.cloud:spring-cloud-context:4.1.4=sampleOffTestCompileClasspath,testCompileClasspath org.springframework.data:spring-data-commons:3.5.13=sampleFixture -org.springframework.data:spring-data-commons:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.data:spring-data-commons:4.0.7=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.data:spring-data-jpa:3.5.13=sampleFixture -org.springframework.data:spring-data-jpa:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.data:spring-data-mongodb:5.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -org.springframework.graphql:spring-graphql:2.0.0=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.springframework.data:spring-data-jpa:4.0.7=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.data:spring-data-mongodb:5.0.7=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.springframework.graphql:spring-graphql:2.0.5=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.springframework.integration:spring-integration-core:6.5.10=sampleFixture -org.springframework.integration:spring-integration-core:7.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.integration:spring-integration-core:7.0.6=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.integration:spring-integration-jdbc:6.5.10=sampleFixture -org.springframework.integration:spring-integration-jdbc:7.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.kafka:spring-kafka:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.springframework.integration:spring-integration-jdbc:7.0.6=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.kafka:spring-kafka:4.0.7=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.springframework.retry:spring-retry:2.0.13=sampleFixture org.springframework.security:spring-security-config:6.5.11=sampleFixture -org.springframework.security:spring-security-config:7.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.security:spring-security-config:7.0.7=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.security:spring-security-core:6.5.11=sampleFixture -org.springframework.security:spring-security-core:7.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.security:spring-security-core:7.0.7=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.security:spring-security-crypto:6.5.11=sampleFixture -org.springframework.security:spring-security-crypto:7.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.security:spring-security-oauth2-client:7.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.security:spring-security-crypto:7.0.7=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.security:spring-security-oauth2-client:7.0.7=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.security:spring-security-oauth2-core:6.5.11=sampleFixture -org.springframework.security:spring-security-oauth2-core:7.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.security:spring-security-oauth2-core:7.0.7=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.security:spring-security-oauth2-jose:6.5.11=sampleFixture -org.springframework.security:spring-security-oauth2-jose:7.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.springframework.security:spring-security-oauth2-jose:7.0.7=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.springframework.security:spring-security-oauth2-resource-server:6.5.11=sampleFixture -org.springframework.security:spring-security-oauth2-resource-server:7.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath -org.springframework.security:spring-security-test:7.0.0=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.security:spring-security-oauth2-resource-server:7.0.7=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.springframework.security:spring-security-test:7.0.7=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework.security:spring-security-web:6.5.11=sampleFixture -org.springframework.security:spring-security-web:7.0.0=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.session:spring-session-core:4.0.0=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.springframework.security:spring-security-web:7.0.7=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.session:spring-session-core:4.0.5=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.springframework:spring-aop:6.2.19=sampleFixture -org.springframework:spring-aop:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-aop:7.0.9=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-aspects:6.2.19=sampleFixture -org.springframework:spring-aspects:7.0.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-aspects:7.0.9=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-beans:6.2.19=sampleFixture -org.springframework:spring-beans:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-context-support:7.0.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.springframework:spring-beans:7.0.9=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-context-support:7.0.9=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.springframework:spring-context:6.2.19=sampleFixture -org.springframework:spring-context:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-context:7.0.9=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-core:6.2.19=sampleFixture -org.springframework:spring-core:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-core:7.0.9=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-expression:6.2.19=sampleFixture -org.springframework:spring-expression:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-expression:7.0.9=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-jcl:6.2.19=sampleFixture org.springframework:spring-jdbc:6.2.19=sampleFixture -org.springframework:spring-jdbc:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-jdbc:7.0.9=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-messaging:6.2.19=sampleFixture -org.springframework:spring-messaging:7.0.1=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-messaging:7.0.9=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-orm:6.2.19=sampleFixture -org.springframework:spring-orm:7.0.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-test:7.0.1=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-orm:7.0.9=productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-test:7.0.9=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-tx:6.2.19=sampleFixture -org.springframework:spring-tx:7.0.1=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-tx:7.0.9=compileClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.springframework:spring-web:6.2.19=sampleFixture -org.springframework:spring-web:7.0.1=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-webflux:7.0.1=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.springframework:spring-web:7.0.9=compileClasspath,conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-webflux:7.0.9=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.springframework:spring-webmvc:6.2.19=sampleFixture -org.springframework:spring-webmvc:7.0.1=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-websocket:7.0.1=conditionalTransportTestRuntimeClasspath,sampleOffTestCompileClasspath,testCompileClasspath -org.testcontainers:testcontainers-database-commons:2.0.2=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.testcontainers:testcontainers-jdbc:2.0.2=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.testcontainers:testcontainers-junit-jupiter:2.0.2=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.testcontainers:testcontainers-postgresql:2.0.2=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.testcontainers:testcontainers:2.0.2=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.thymeleaf:thymeleaf:3.1.3.RELEASE=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath +org.springframework:spring-webmvc:7.0.9=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-websocket:7.0.9=conditionalTransportTestRuntimeClasspath,sampleOffTestCompileClasspath,testCompileClasspath +org.testcontainers:testcontainers-database-commons:2.0.5=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.testcontainers:testcontainers-jdbc:2.0.5=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.testcontainers:testcontainers-junit-jupiter:2.0.5=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.testcontainers:testcontainers-postgresql:2.0.5=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.testcontainers:testcontainers:2.0.5=sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.thymeleaf:thymeleaf:3.1.5.RELEASE=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.unbescape:unbescape:1.1.6.RELEASE=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.xerial.snappy:snappy-java:1.1.10.7=productionRuntimeClasspath,runtimeClasspath,sampleOffTestRuntimeClasspath,testRuntimeClasspath org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs @@ -553,7 +542,7 @@ software.amazon.awssdk:sdk-core:2.30.0=testRuntimeClasspath software.amazon.awssdk:third-party-jackson-core:2.30.0=testRuntimeClasspath software.amazon.awssdk:utils:2.30.0=testRuntimeClasspath software.amazon.eventstream:eventstream:1.0.1=testRuntimeClasspath -tools.jackson.core:jackson-core:3.0.2=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -tools.jackson.core:jackson-databind:3.0.2=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -tools.jackson:jackson-bom:3.0.2=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +tools.jackson.core:jackson-core:3.1.5=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +tools.jackson.core:jackson-databind:3.1.5=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +tools.jackson:jackson-bom:3.1.5=conditionalTransportTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,sampleOffTestCompileClasspath,sampleOffTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath empty=developmentOnly,testAndDevelopmentOnly diff --git a/src/app-bootstrap/src/conditionalTransportTest/java/dev/caskeleton/bootstrap/transport/ConditionalTransportCompositionContractTest.java b/src/app-bootstrap/src/conditionalTransportTest/java/dev/caskeleton/bootstrap/transport/ConditionalTransportCompositionContractTest.java index afa139fe..ebd5f68c 100644 --- a/src/app-bootstrap/src/conditionalTransportTest/java/dev/caskeleton/bootstrap/transport/ConditionalTransportCompositionContractTest.java +++ b/src/app-bootstrap/src/conditionalTransportTest/java/dev/caskeleton/bootstrap/transport/ConditionalTransportCompositionContractTest.java @@ -48,7 +48,7 @@ class ConditionalTransportCompositionContractTest { "adapter-inbound-grpc", "dev.caskeleton.adapter.inbound.grpc.GrpcServerConfig", "adapter-inbound-websocket", - "dev.caskeleton.adapter.inbound.websocket.WebSocketConfig"); + "dev.caskeleton.adapter.inbound.websocket.stomp.WebSocketConfig"); /** Transports that ship on the one bootJar and are decided by a master switch. */ private static final Map SWITCH_GATED_TRANSPORTS = diff --git a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/CaSkeletonApplication.java b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/CaSkeletonApplication.java index d4c83eca..f5751345 100644 --- a/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/CaSkeletonApplication.java +++ b/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/CaSkeletonApplication.java @@ -89,6 +89,13 @@ public class CaSkeletonApplication { *

The Fileserver admin package is here for a different reason: those routes belong to the * management context, and a component scan that also found them would publish the management * plane on the public connector — the exposure the separate context exists to remove. + * + *

The web platform's error, budget and operation packages are here for a third reason. Their + * advices and controllers need beans that only exist when the corresponding platform + * auto-configuration is active, and a component scan finds them regardless — so an all-off or + * partially configured deployment failed to start on an unsatisfied dependency rather than simply + * not installing the control. Ownership by auto-configuration is what ties a control's presence + * to its dependency's. */ static final String AUTO_CONFIGURED_PACKAGES = "dev\\.caskeleton\\.bootstrap\\.autoconfigure\\..*" @@ -99,6 +106,11 @@ public class CaSkeletonApplication { + "|dev\\.caskeleton\\.adapter\\.outbound\\.notification\\..*" + "|dev\\.caskeleton\\.adapter\\.outbound\\.persistence\\..*" + "|dev\\.caskeleton\\.adapter\\.inbound\\.graphql\\..*" + + "|dev\\.caskeleton\\.adapter\\.inbound\\.web\\.mvc\\.error\\..*" + + "|dev\\.caskeleton\\.adapter\\.inbound\\.web\\.mvc\\.budget\\..*" + + "|dev\\.caskeleton\\.adapter\\.inbound\\.web\\.mvc\\.operation\\..*" + + "|dev\\.caskeleton\\.adapter\\.inbound\\.web\\.webflux\\.error\\..*" + + "|dev\\.caskeleton\\.adapter\\.inbound\\.web\\.webflux\\.operation\\..*" + "|dev\\.caskeleton\\.messaging\\..*"; public static void main(String[] args) { diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/WebProductionArchitectureTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/WebProductionArchitectureTest.java new file mode 100644 index 00000000..88097170 --- /dev/null +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/architecture/WebProductionArchitectureTest.java @@ -0,0 +1,53 @@ +package dev.caskeleton.bootstrap.architecture; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.core.importer.ClassFileImporter; +import dev.caskeleton.adapter.inbound.web.testkit.arch.WebArchitectureRules; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * The web platform's boundary rules, run against the real production graph. + * + *

The rule pack lives in the web leaf's testkit, and a rule pack exercised only by its own + * fixtures is verified as library code and applied to nothing — the shape the JPA testkit had to be + * corrected out of. The composition root is the only place that sees the application, the domain + * and every transport at once, so it is where the production suite belongs. + */ +class WebProductionArchitectureTest { + + // The suite's shared option. Excluding jars would exclude every runtime leaf, because the + // composition root sees them as dependencies — and the import would then be empty for the most + // ordinary reason possible. Sharing the instance also shares ArchUnit's class cache, which is + // what keeps a third suite in this JVM from being an OutOfMemoryError rather than a slower run. + private static final JavaClasses PRODUCTION = + new ClassFileImporter() + .withImportOption(new ProductionClassImportOption()) + .importPackages("dev.caskeleton"); + + @Test + @DisplayName("the import actually reaches the production graph") + void theImportReachesTheProductionGraph() { + assertThat(PRODUCTION.size()) + .as("a suite that imported nothing would report every rule as satisfied") + .isGreaterThan(500); + assertThat( + PRODUCTION.stream() + .anyMatch(type -> type.getPackageName().startsWith("dev.caskeleton.application"))) + .as("the cross-leaf rule needs the application packages to be in the import") + .isTrue(); + } + + @Test + @DisplayName("every web boundary rule holds on the production graph") + void everyWebBoundaryRuleHoldsOnProduction() { + for (var rule : WebArchitectureRules.all()) { + assertThatCode(() -> rule.check(PRODUCTION)) + .as("production violates: %s", rule.getDescription()) + .doesNotThrowAnyException(); + } + } +} diff --git a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/ConditionalTransportQualificationContractTest.java b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/ConditionalTransportQualificationContractTest.java index ab238d3b..a548c2f0 100644 --- a/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/ConditionalTransportQualificationContractTest.java +++ b/src/app-bootstrap/src/test/java/dev/caskeleton/bootstrap/contract/ConditionalTransportQualificationContractTest.java @@ -142,7 +142,8 @@ class ConditionalTransportQualificationContractTest { .contains("dev.caskeleton.adapter.inbound.grpc.GrpcP1BoundaryWireTest"); assertThat(websocket) .contains("registerStrictQualificationTest") - .contains("dev.caskeleton.adapter.inbound.websocket.WebSocketBoundaryQualificationTest"); + .contains( + "dev.caskeleton.adapter.inbound.websocket.stomp.WebSocketBoundaryQualificationTest"); assertThat(rootBuild) .contains("tasks.register('conditionalTransportQualification')") .contains(":adapter:inbound:graphql:graphqlTransportQualificationTest") diff --git a/src/application-core/gradle.lockfile b/src/application-core/gradle.lockfile index 226184d0..e882aefc 100644 --- a/src/application-core/gradle.lockfile +++ b/src/application-core/gradle.lockfile @@ -1,7 +1,8 @@ # This is a Gradle generated file for dependency locking. # Manual edits can break the build and are not advised. # This file is expected to be part of source control. -com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,redisPolicyContractTestAnnotationProcessor,testAnnotationProcessor +com.github.ben-manes.caffeine:caffeine:3.2.3=redisPolicyContractTestAnnotationProcessor +com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,testAnnotationProcessor com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,redisPolicyContractTestAnnotationProcessor,testAnnotationProcessor com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs com.github.spotbugs:spotbugs:4.10.2=spotbugs @@ -33,7 +34,7 @@ info.picocli:picocli:4.7.7=checkstyle io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,redisPolicyContractTestAnnotationProcessor,testAnnotationProcessor io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,redisPolicyContractTestAnnotationProcessor,testAnnotationProcessor javax.inject:javax.inject:1=annotationProcessor,redisPolicyContractTestAnnotationProcessor,testAnnotationProcessor -jaxen:jaxen:2.0.0=spotbugs +jaxen:jaxen:2.0.6=spotbugs net.bytebuddy:byte-buddy:1.17.8=redisPolicyContractTestCompileClasspath,redisPolicyContractTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath net.jqwik:jqwik-api:1.9.1=redisPolicyContractTestCompileClasspath,redisPolicyContractTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath net.jqwik:jqwik-engine:1.9.1=redisPolicyContractTestRuntimeClasspath,testRuntimeClasspath @@ -48,30 +49,40 @@ org.apache.commons:commons-text:1.15.0=spotbugs org.apache.commons:commons-text:1.3=checkstyle org.apache.httpcomponents:httpclient:4.5.13=checkstyle org.apache.httpcomponents:httpcore:4.4.16=checkstyle -org.apache.logging.log4j:log4j-api:2.25.2=spotbugs -org.apache.logging.log4j:log4j-core:2.25.2=spotbugs +org.apache.logging.log4j:log4j-api:2.25.5=spotbugs +org.apache.logging.log4j:log4j-core:2.25.5=spotbugs org.apache.maven.doxia:doxia-core:1.12.0=checkstyle org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle org.apache.xbean:xbean-reflect:3.7=checkstyle org.apiguardian:apiguardian-api:1.1.2=redisPolicyContractTestCompileClasspath,redisPolicyContractTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.assertj:assertj-core:3.27.6=redisPolicyContractTestCompileClasspath,redisPolicyContractTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.assertj:assertj-core:3.27.6=redisPolicyContractTestCompileClasspath,redisPolicyContractTestRuntimeClasspath +org.assertj:assertj-core:3.27.7=testCompileClasspath,testRuntimeClasspath org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle org.codehaus.plexus:plexus-utils:3.3.0=checkstyle org.dom4j:dom4j:2.2.0=spotbugs org.javassist:javassist:3.28.0-GA=checkstyle -org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,redisPolicyContractTestAnnotationProcessor,redisPolicyContractTestCompileClasspath,testAnnotationProcessor,testCompileClasspath -org.junit.jupiter:junit-jupiter-api:6.0.1=redisPolicyContractTestCompileClasspath,redisPolicyContractTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-engine:6.0.1=redisPolicyContractTestRuntimeClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-params:6.0.1=redisPolicyContractTestCompileClasspath,redisPolicyContractTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter:6.0.1=redisPolicyContractTestCompileClasspath,redisPolicyContractTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-commons:6.0.1=redisPolicyContractTestCompileClasspath,redisPolicyContractTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-engine:6.0.1=redisPolicyContractTestRuntimeClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-launcher:6.0.1=redisPolicyContractTestRuntimeClasspath,testRuntimeClasspath -org.junit:junit-bom:6.0.1=redisPolicyContractTestCompileClasspath,redisPolicyContractTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.jspecify:jspecify:1.0.0=redisPolicyContractTestAnnotationProcessor,redisPolicyContractTestCompileClasspath +org.jspecify:jspecify:1.0.1=annotationProcessor,checkstyle,testAnnotationProcessor,testCompileClasspath +org.junit.jupiter:junit-jupiter-api:6.0.1=redisPolicyContractTestCompileClasspath,redisPolicyContractTestRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.1=redisPolicyContractTestRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.3=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.1=redisPolicyContractTestCompileClasspath,redisPolicyContractTestRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.1=redisPolicyContractTestCompileClasspath,redisPolicyContractTestRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.1=redisPolicyContractTestCompileClasspath,redisPolicyContractTestRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.1=redisPolicyContractTestRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.3=testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.1=redisPolicyContractTestRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.3=testRuntimeClasspath +org.junit:junit-bom:6.0.1=redisPolicyContractTestCompileClasspath,redisPolicyContractTestRuntimeClasspath +org.junit:junit-bom:6.0.3=testCompileClasspath,testRuntimeClasspath org.junit:junit-bom:6.1.0=spotbugs org.mockito:mockito-core:5.20.0=mockitoAgent org.opentest4j:opentest4j:1.3.0=redisPolicyContractTestCompileClasspath,redisPolicyContractTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath @@ -82,7 +93,8 @@ org.ow2.asm:asm-util:9.10.1=spotbugs org.ow2.asm:asm:9.10.1=spotbugs org.pcollections:pcollections:4.0.1=annotationProcessor,redisPolicyContractTestAnnotationProcessor,testAnnotationProcessor org.reflections:reflections:0.10.2=checkstyle -org.slf4j:slf4j-api:2.0.17=spotbugs,spotbugsSlf4j -org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j +org.slf4j:slf4j-api:2.0.18=spotbugs,spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.18=checkstyle org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs empty=compileClasspath,runtimeClasspath diff --git a/src/application-core/src/main/java/dev/caskeleton/application/operation/DurableOperation.java b/src/application-core/src/main/java/dev/caskeleton/application/operation/DurableOperation.java new file mode 100644 index 00000000..b2bbec50 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/operation/DurableOperation.java @@ -0,0 +1,100 @@ +package dev.caskeleton.application.operation; + +import java.time.Instant; +import java.util.Objects; +import java.util.Optional; + +/** + * One durable operation as stored. + * + *

Durable is the point. A {@code CompletableFuture} or a scheduler handle is process-local: it + * disappears with the node that made it, and a caller polling for it is told the work does not + * exist while it is still running elsewhere. Everything in this record survives a restart. + * + *

The constructor refuses states that cannot be true at once. Each is a specific way a poller + * ends up unable to act: a SUCCEEDED operation with no result reference, a FAILED one with no + * recorded reason, a RUNNING one with no lease. + * + * @param operationId the identity callers poll + * @param operationName what work this is + * @param principal who submitted it + * @param tenantId the tenant, or null when the operation is not tenant scoped + * @param state where in the lifecycle it is + * @param submittedAt when it was accepted + * @param startedAt when a worker first picked it up + * @param completedAt when it reached a terminal state + * @param progress how far it has got + * @param resultReference where a successful result can be read + * @param failure why it failed + * @param lease the worker's claim, while one is held + * @param expiresAt when this record stops being available + */ +public record DurableOperation( + DurableOperationId operationId, + String operationName, + String principal, + String tenantId, + DurableOperationState state, + Instant submittedAt, + Optional startedAt, + Optional completedAt, + Optional progress, + Optional resultReference, + Optional failure, + Optional lease, + Instant expiresAt) { + + public DurableOperation { + Objects.requireNonNull(operationId, "operationId"); + Objects.requireNonNull(operationName, "operationName"); + Objects.requireNonNull(principal, "principal"); + Objects.requireNonNull(state, "state"); + Objects.requireNonNull(submittedAt, "submittedAt"); + Objects.requireNonNull(startedAt, "startedAt"); + Objects.requireNonNull(completedAt, "completedAt"); + Objects.requireNonNull(progress, "progress"); + Objects.requireNonNull(resultReference, "resultReference"); + Objects.requireNonNull(failure, "failure"); + Objects.requireNonNull(lease, "lease"); + Objects.requireNonNull(expiresAt, "expiresAt"); + + if (state == DurableOperationState.SUCCEEDED && resultReference.isEmpty()) { + throw new IllegalArgumentException( + "a SUCCEEDED operation must record where its result is; a success nobody can read is" + + " indistinguishable from a lost one"); + } + if (state == DurableOperationState.FAILED && failure.isEmpty()) { + throw new IllegalArgumentException( + "a FAILED operation must record why; a failure with no reason gives the caller nothing" + + " to report and nothing to decide on"); + } + if (state != DurableOperationState.FAILED && failure.isPresent()) { + throw new IllegalArgumentException("only a FAILED operation records a failure"); + } + if (state.terminal() && completedAt.isEmpty()) { + throw new IllegalArgumentException("a " + state + " operation must record when it finished"); + } + if (!state.terminal() && completedAt.isPresent()) { + throw new IllegalArgumentException("a " + state + " operation has not finished"); + } + if (state == DurableOperationState.RUNNING && lease.isEmpty()) { + throw new IllegalArgumentException( + "a RUNNING operation must hold a lease; without one nothing can tell a live worker from" + + " a dead one, and the operation is stranded"); + } + if (state.terminal() && lease.isPresent()) { + throw new IllegalArgumentException( + "a finished operation still holding a lease blocks reclaim"); + } + } + + /** Whether a poller should stop polling. */ + public boolean finished() { + return state.terminal(); + } + + /** Whether the lease, if any, has lapsed and the operation can be reclaimed. */ + public boolean leaseExpiredAt(Instant now) { + return lease.map(held -> held.expiredAt(now)).orElse(false); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/operation/DurableOperationId.java b/src/application-core/src/main/java/dev/caskeleton/application/operation/DurableOperationId.java new file mode 100644 index 00000000..573e0135 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/operation/DurableOperationId.java @@ -0,0 +1,30 @@ +package dev.caskeleton.application.operation; + +import java.util.Objects; +import java.util.regex.Pattern; + +/** + * The identity of one durable operation. + * + * @param value the identifier + */ +public record DurableOperationId(String value) { + + // Bounded and free of anything that needs escaping. The identifier travels to whatever transport + // published the operation and is handed back to callers, so a value that one encoding mangles is + // a defect that only appears for some clients. + private static final Pattern GRAMMAR = Pattern.compile("[A-Za-z0-9._~-]{1,128}"); + + public DurableOperationId { + Objects.requireNonNull(value, "value"); + if (!GRAMMAR.matcher(value).matches()) { + throw new IllegalArgumentException( + "a durable operation id must match [A-Za-z0-9._~-]{1,128}"); + } + } + + @Override + public String toString() { + return value; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/operation/DurableOperationState.java b/src/application-core/src/main/java/dev/caskeleton/application/operation/DurableOperationState.java new file mode 100644 index 00000000..9349b7a3 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/operation/DurableOperationState.java @@ -0,0 +1,38 @@ +package dev.caskeleton.application.operation; + +/** + * The lifecycle of a durable operation as the application sees it. + * + *

Deliberately separate from any transport's rendering of it. A transport may choose to expose + * fewer states or different names; it may not invent a state the store cannot be in. + */ +public enum DurableOperationState { + + /** Accepted and durable, waiting for a worker. */ + PENDING, + + /** A worker holds an unexpired lease. */ + RUNNING, + + /** Finished; a result reference is recorded. */ + SUCCEEDED, + + /** Finished; a failure is recorded and no retry will happen. */ + FAILED, + + /** Stopped on request before it finished. */ + CANCELED, + + /** The record aged out. The work may have succeeded; the record no longer says. */ + EXPIRED; + + /** Whether no further transition is possible. */ + public boolean terminal() { + return this == SUCCEEDED || this == FAILED || this == CANCELED || this == EXPIRED; + } + + /** Whether cancellation is still meaningful. */ + public boolean cancellable() { + return this == PENDING || this == RUNNING; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/operation/DurableOperationStorePort.java b/src/application-core/src/main/java/dev/caskeleton/application/operation/DurableOperationStorePort.java new file mode 100644 index 00000000..2b4772b5 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/operation/DurableOperationStorePort.java @@ -0,0 +1,111 @@ +package dev.caskeleton.application.operation; + +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Optional; + +/** + * Where durable operations live. + * + *

A port because the application owns what an operation is and nothing about how it is stored. + * The JPA adapter behind it is one implementation; an in-memory one serves tests. + * + *

Every method that changes a running operation takes the {@code workerId} and verifies it. A + * worker whose lease lapsed may still be alive and still finishing its work, and letting it report + * a result after another worker took over is how an operation ends up with two answers. + */ +public interface DurableOperationStorePort { + + /** + * Records an accepted submission, or returns the operation an identical one already created. + * + *

Identical means the same scope and the same fingerprint. Returning the existing operation + * rather than a second one is what makes a resubmission safe while the first is still running. + * + * @param submission what was accepted + * @return the operation, whether newly created or already present + */ + DurableOperation submit(DurableOperationSubmission submission); + + /** + * Reads an operation the caller is entitled to see. + * + * @param operationId the identity being polled + * @param principal the caller + * @param now the instant expiry is judged against + */ + Optional find(DurableOperationId operationId, String principal, Instant now); + + /** + * Takes a lease on the next runnable operation. + * + *

Runnable means PENDING, or RUNNING with a lapsed lease. The second case is the recovery + * path: without it, an operation claimed by a worker that then died is never picked up again. + * + * @param workerId who is claiming + * @param leaseDuration how long the claim holds without a heartbeat + * @param now the current instant + */ + Optional claimNext( + String workerId, Duration leaseDuration, Instant now); + + /** + * Extends a lease the worker still holds. + * + * @return false when the lease is no longer this worker's, which means it must stop + */ + boolean heartbeat( + DurableOperationId operationId, String workerId, Duration leaseDuration, Instant now); + + /** + * Publishes progress for an operation this worker holds. + * + * @return false when the lease is no longer this worker's + */ + boolean reportProgress( + DurableOperationId operationId, String workerId, OperationProgressSnapshot progress); + + /** + * Records success. + * + * @return false when the lease is no longer this worker's, so the result is not recorded + */ + boolean succeed( + DurableOperationId operationId, String workerId, String resultReference, Instant completedAt); + + /** + * Records failure. + * + * @return false when the lease is no longer this worker's + */ + boolean fail( + DurableOperationId operationId, + String workerId, + OperationFailure failure, + Instant completedAt); + + /** + * Requests cancellation. + * + *

Idempotent, and never reverses a terminal state: cancelling an operation that already + * succeeded would replace a real answer with a lie about it. + * + * @return whether this call moved the operation to CANCELED + */ + boolean cancel(DurableOperationId operationId, String principal, Instant canceledAt); + + /** + * Returns operations whose lease lapsed to PENDING. + * + * @return the operations reclaimed + */ + List reclaimExpiredLeases(Instant now); + + /** + * Marks records that outlived their TTL as EXPIRED. + * + * @return the operations expired + */ + List expireStaleOperations(Instant now); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/operation/DurableOperationSubmission.java b/src/application-core/src/main/java/dev/caskeleton/application/operation/DurableOperationSubmission.java new file mode 100644 index 00000000..42592f35 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/operation/DurableOperationSubmission.java @@ -0,0 +1,62 @@ +package dev.caskeleton.application.operation; + +import dev.caskeleton.application.idempotency.RequestFingerprint; +import java.time.Duration; +import java.time.Instant; +import java.util.Objects; + +/** + * What is durably written when long-running work is accepted. + * + *

Separate from {@link DurableOperation} because they have different readers: this is written + * once at admission and carries the payload a worker will execute; the operation is read on every + * poll. Folding them together would put the submitted payload into the document handed back to + * every poller. + * + *

It carries the request fingerprint so a resubmission under the same key returns the operation + * already accepted rather than starting a second one. An accepted-but-unfinished operation is + * precisely the state in which a client is most likely to retry. + * + * @param operationId the identity handed back + * @param operationName what work this is + * @param principal who submitted it + * @param tenantId the tenant, or null when the operation is not tenant scoped + * @param fingerprint the semantic fingerprint of the submitted request + * @param payload the serialized request a worker will execute + * @param submittedAt when it was accepted + * @param timeToLive how long the record and its result stay available + */ +public record DurableOperationSubmission( + DurableOperationId operationId, + String operationName, + String principal, + String tenantId, + RequestFingerprint fingerprint, + String payload, + Instant submittedAt, + Duration timeToLive) { + + public DurableOperationSubmission { + Objects.requireNonNull(operationId, "operationId"); + Objects.requireNonNull(operationName, "operationName"); + Objects.requireNonNull(fingerprint, "fingerprint"); + Objects.requireNonNull(payload, "payload"); + Objects.requireNonNull(submittedAt, "submittedAt"); + Objects.requireNonNull(timeToLive, "timeToLive"); + if (principal == null || principal.isBlank()) { + throw new IllegalArgumentException( + "a submission needs a principal; without one any caller could poll any operation"); + } + if (operationName.isBlank()) { + throw new IllegalArgumentException("a submission needs the operation it is for"); + } + if (timeToLive.isZero() || timeToLive.isNegative()) { + throw new IllegalArgumentException("a submission with no lifetime cannot be polled"); + } + } + + /** When this operation's record stops being available. */ + public Instant expiresAt() { + return submittedAt.plus(timeToLive); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/operation/OperationAcceptance.java b/src/application-core/src/main/java/dev/caskeleton/application/operation/OperationAcceptance.java new file mode 100644 index 00000000..d6bad733 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/operation/OperationAcceptance.java @@ -0,0 +1,26 @@ +package dev.caskeleton.application.operation; + +import java.time.Instant; +import java.util.Objects; + +/** + * What the caller is told after a submission commits. + * + *

{@code alreadyAccepted} is the part that matters. A resubmission of identical work returns the + * operation that already exists, and the transport needs to know which case it is: a first + * acceptance is a 202 with a fresh {@code Location}, and a repeat is a 202 pointing at the same + * operation. Without the flag the transport cannot tell, and the honest thing — pointing the client + * at the work that is actually running — becomes indistinguishable from silently starting a second. + * + * @param operationId the identity the caller polls + * @param acceptedAt when the operation was first accepted, not when this call happened + * @param alreadyAccepted whether an identical submission had already created it + */ +public record OperationAcceptance( + DurableOperationId operationId, Instant acceptedAt, boolean alreadyAccepted) { + + public OperationAcceptance { + Objects.requireNonNull(operationId, "operationId"); + Objects.requireNonNull(acceptedAt, "acceptedAt"); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/operation/OperationCommand.java b/src/application-core/src/main/java/dev/caskeleton/application/operation/OperationCommand.java new file mode 100644 index 00000000..0a397786 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/operation/OperationCommand.java @@ -0,0 +1,52 @@ +package dev.caskeleton.application.operation; + +import dev.caskeleton.application.idempotency.RequestFingerprint; +import java.time.Duration; +import java.util.Objects; + +/** + * A request to run work asynchronously. + * + *

The caller supplies the operation id rather than the store minting one. That is what lets the + * transport hand back a {@code Location} it already knows, and it makes the whole submission one + * value the caller can retry: a generated id would differ on every attempt, so a retry after an + * ambiguous failure would create a second operation. + * + * @param operationId the identity to use + * @param operationName what work this is + * @param principal who is asking + * @param tenantId the tenant, or null when the operation is not tenant scoped + * @param fingerprint the semantic fingerprint of the request + * @param payload the serialized request a worker will execute + * @param eventType the outbox event type a worker subscribes to + * @param timeToLive how long the record and its result stay available + */ +public record OperationCommand( + DurableOperationId operationId, + String operationName, + String principal, + String tenantId, + RequestFingerprint fingerprint, + String payload, + String eventType, + Duration timeToLive) { + + public OperationCommand { + Objects.requireNonNull(operationId, "operationId"); + Objects.requireNonNull(operationName, "operationName"); + Objects.requireNonNull(fingerprint, "fingerprint"); + Objects.requireNonNull(payload, "payload"); + Objects.requireNonNull(timeToLive, "timeToLive"); + if (principal == null || principal.isBlank()) { + throw new IllegalArgumentException("an operation command needs a principal"); + } + if (operationName.isBlank()) { + throw new IllegalArgumentException("an operation command needs the operation it is for"); + } + if (eventType == null || eventType.isBlank()) { + throw new IllegalArgumentException( + "an operation command needs an event type; without one the outbox row reaches no worker" + + " and the operation is accepted into silence"); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/operation/OperationFailure.java b/src/application-core/src/main/java/dev/caskeleton/application/operation/OperationFailure.java new file mode 100644 index 00000000..1dd6bd67 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/operation/OperationFailure.java @@ -0,0 +1,26 @@ +package dev.caskeleton.application.operation; + +import java.util.Objects; + +/** + * Why a durable operation failed, in terms a transport can publish. + * + *

A code and an already-safe message rather than an exception, because this is stored and later + * handed to whoever polls. An exception would carry a stack trace, a SQL fragment, or a host name + * into a client-visible document, and the moment it is persisted there is no longer anywhere to + * sanitise it. + * + * @param code the stable failure code the transport maps to its own vocabulary + * @param detail a message already safe to publish + * @param retryable whether resubmitting the same work could succeed + */ +public record OperationFailure(String code, String detail, boolean retryable) { + + public OperationFailure { + Objects.requireNonNull(code, "code"); + Objects.requireNonNull(detail, "detail"); + if (code.isBlank()) { + throw new IllegalArgumentException("a failure needs a code a caller can branch on"); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/operation/OperationLease.java b/src/application-core/src/main/java/dev/caskeleton/application/operation/OperationLease.java new file mode 100644 index 00000000..86328d84 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/operation/OperationLease.java @@ -0,0 +1,42 @@ +package dev.caskeleton.application.operation; + +import java.time.Instant; +import java.util.Objects; + +/** + * A worker's expiring claim on an operation. + * + *

Expiring is what makes it a lease rather than a flag. A worker can be killed between claiming + * an operation and finishing it, and a claim that never lapses leaves the operation RUNNING for + * ever with no worker behind it — the failure that makes a queue stop draining while every + * dashboard reports it busy. + * + * @param workerId who holds it + * @param acquiredAt when it was taken + * @param expiresAt when another worker may take it + */ +public record OperationLease(String workerId, Instant acquiredAt, Instant expiresAt) { + + public OperationLease { + Objects.requireNonNull(workerId, "workerId"); + Objects.requireNonNull(acquiredAt, "acquiredAt"); + Objects.requireNonNull(expiresAt, "expiresAt"); + if (workerId.isBlank()) { + throw new IllegalArgumentException( + "a lease needs an owner; an anonymous one cannot be verified when the work completes"); + } + if (!expiresAt.isAfter(acquiredAt)) { + throw new IllegalArgumentException("a lease that expires when taken is not a lease"); + } + } + + /** Whether this lease has lapsed. */ + public boolean expiredAt(Instant now) { + return !now.isBefore(expiresAt); + } + + /** The same lease, held longer. */ + public OperationLease extendedTo(Instant newExpiry) { + return new OperationLease(workerId, acquiredAt, newExpiry); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/operation/OperationProgressSnapshot.java b/src/application-core/src/main/java/dev/caskeleton/application/operation/OperationProgressSnapshot.java new file mode 100644 index 00000000..90aba847 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/operation/OperationProgressSnapshot.java @@ -0,0 +1,35 @@ +package dev.caskeleton.application.operation; + +import java.util.Objects; +import java.util.Optional; + +/** + * How far a running operation has got. + * + *

The total is optional because most work honestly cannot predict it. A store that required one + * would get an invented number, and clients build progress bars out of invented numbers. + * + * @param completedUnits work finished so far + * @param totalUnits work expected in total, when known + * @param phase a label for the current stage, when the work has stages + */ +public record OperationProgressSnapshot( + long completedUnits, Optional totalUnits, Optional phase) { + + public OperationProgressSnapshot { + Objects.requireNonNull(totalUnits, "totalUnits"); + Objects.requireNonNull(phase, "phase"); + if (completedUnits < 0) { + throw new IllegalArgumentException("completed units cannot be negative"); + } + if (totalUnits.isPresent() && totalUnits.get() < completedUnits) { + throw new IllegalArgumentException( + "an operation cannot have completed more units than it has"); + } + } + + /** Progress with no known total. */ + public static OperationProgressSnapshot of(long completedUnits) { + return new OperationProgressSnapshot(completedUnits, Optional.empty(), Optional.empty()); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/operation/SubmitDurableOperationUseCase.java b/src/application-core/src/main/java/dev/caskeleton/application/operation/SubmitDurableOperationUseCase.java new file mode 100644 index 00000000..1729d0fa --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/operation/SubmitDurableOperationUseCase.java @@ -0,0 +1,121 @@ +package dev.caskeleton.application.operation; + +import dev.caskeleton.application.capability.Idempotency; +import dev.caskeleton.application.capability.RepositoryAccess; +import dev.caskeleton.application.capability.UseCaseCapability; +import dev.caskeleton.application.outbox.NewOutboxEvent; +import dev.caskeleton.application.outbox.OutboxAppendPort; +import dev.caskeleton.application.security.RequiresPermission; +import dev.caskeleton.application.transaction.TransactionMode; +import dev.caskeleton.application.transaction.TransactionPort; +import java.time.Clock; +import java.util.Objects; + +/** + * Accepts long-running work: one transaction that writes the operation row and the outbox row. + * + *

Both in one transaction, and the 202 only after it commits. The two obvious alternatives are + * both broken in ways that only show under failure: + * + *

    + *
  • Publishing to the broker directly, then writing the row — the publish can succeed and the + * row can fail, so a worker starts work that nothing is tracking, and the client is told the + * submission failed. + *
  • Writing the row, committing, then publishing — the commit can succeed and the publish can + * fail, so the operation sits PENDING for ever and nothing ever picks it up. + *
+ * + *

The outbox exists precisely so neither happens: the row and the intent to publish commit + * together or not at all, and the relay makes the publish eventually happen. A duplicate publish + * from the relay is fine — it carries the same operation id, so a worker's claim deduplicates it. + * + *

This class does not touch the broker. Acknowledgement, retry and dead-lettering belong to the + * messaging platform, and reimplementing any of them here would be a second, weaker copy of a + * subsystem this repository already vendors. + */ +// Accepting long-running work is a write, so it takes a permission gate like any other. The +// operation it will eventually run has its own authorization; this one governs who may enqueue. +@RequiresPermission("operation:submit") +@UseCaseCapability( + transactionMode = TransactionMode.WRITE, + // KEYED, not IDEMPOTENT. Re-running this use case is only safe because the caller supplies the + // operation id and the fingerprint scope; without a key it would accept the same work twice. + idempotency = Idempotency.KEYED, + repositoryAccess = RepositoryAccess.WRITE_REPOSITORY, + // False, and load-bearing. Nothing here reaches the broker: the outbox row is the publish + // intent and the relay performs it. A use case that published directly could not be in one + // transaction with the operation row, which is the whole point. + externalOutboundAllowed = false) +// Not final: @RequiresPermission is read by an AOP-proxying authorization manager, and a final +// class cannot be proxied — so the gate would be declared and never applied. +// RequiresPermissionProxyabilityTest +// enforces this, which is the only reason the omission is not a silent one. +public class SubmitDurableOperationUseCase { + + private final DurableOperationStorePort operations; + private final OutboxAppendPort outbox; + private final TransactionPort transactions; + private final Clock clock; + + /** + * A use case over the operation store and the outbox. + * + * @param operations where operations are recorded + * @param outbox where the publish intent is recorded + * @param transactions opens the write transaction both share + * @param clock the submission clock + */ + public SubmitDurableOperationUseCase( + DurableOperationStorePort operations, + OutboxAppendPort outbox, + TransactionPort transactions, + Clock clock) { + this.operations = Objects.requireNonNull(operations, "operations"); + this.outbox = Objects.requireNonNull(outbox, "outbox"); + this.transactions = Objects.requireNonNull(transactions, "transactions"); + this.clock = Objects.requireNonNull(clock, "clock"); + } + + /** + * Accepts the command, or returns the operation an identical one already created. + * + * @param command what to run + * @return what to tell the caller + */ + public OperationAcceptance submit(OperationCommand command) { + Objects.requireNonNull(command, "command"); + return transactions.inWrite( + () -> { + DurableOperationSubmission submission = + new DurableOperationSubmission( + command.operationId(), + command.operationName(), + command.principal(), + command.tenantId(), + command.fingerprint(), + command.payload(), + clock.instant(), + command.timeToLive()); + DurableOperation operation = operations.submit(submission); + boolean alreadyAccepted = !operation.operationId().equals(command.operationId()); + + if (!alreadyAccepted) { + // Appended only for a genuinely new operation. A second outbox row for the same + // operation would have a worker claim it, find it already RUNNING or finished, and + // discard the message — harmless but indistinguishable in the logs from a real + // duplicate, which is exactly the signal an operator needs to stay trustworthy. + outbox.append( + new NewOutboxEvent( + operation.operationId().value(), + command.eventType(), + operation.operationId().value(), + command.payload(), + operation.submittedAt(), + operation.operationId().value(), + operation.operationId().value())); + } + return new OperationAcceptance( + operation.operationId(), operation.submittedAt(), alreadyAccepted); + }); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/realtime/ActorFingerprint.java b/src/application-core/src/main/java/dev/caskeleton/application/realtime/ActorFingerprint.java new file mode 100644 index 00000000..24925855 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/realtime/ActorFingerprint.java @@ -0,0 +1,36 @@ +package dev.caskeleton.application.realtime; + +import java.util.Objects; +import java.util.regex.Pattern; + +/** + * A pseudonymous handle for whoever is connected. + * + *

A fingerprint and never an identity, and the type exists to make that non-optional. Everything + * downstream of this port writes the value into a datastore key, a broker partition key or a metric + * tag — places with none of the access controls the application has, and places whose contents end + * up in backups, slow logs and incident screenshots. A raw user id or tenant name there is a + * disclosure that no later code change can take back. + * + *

The grammar is enforced rather than documented for the same reason: a caller that passes an + * email address gets a failure at the boundary instead of a Redis key containing an email address. + * + * @param value the fingerprint + */ +public record ActorFingerprint(String value) { + + private static final Pattern GRAMMAR = Pattern.compile("[a-zA-Z0-9][a-zA-Z0-9._:-]{7,127}"); + + public ActorFingerprint { + Objects.requireNonNull(value, "value"); + if (!GRAMMAR.matcher(value).matches()) { + throw new IllegalArgumentException( + "an actor fingerprint is 8..128 characters of [a-zA-Z0-9._:-] starting alphanumeric; a" + + " value outside that is usually a raw identifier, and this one reaches datastore" + + " keys and metric tags"); + } + if (value.indexOf('@') >= 0) { + throw new IllegalArgumentException("an address is not a fingerprint"); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/realtime/ConnectionRegistration.java b/src/application-core/src/main/java/dev/caskeleton/application/realtime/ConnectionRegistration.java new file mode 100644 index 00000000..16352aa4 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/realtime/ConnectionRegistration.java @@ -0,0 +1,42 @@ +package dev.caskeleton.application.realtime; + +import java.time.Instant; +import java.util.Objects; + +/** + * One node's report that it is holding connections for an actor. + * + *

A report, not a fact. The node that wrote it is the only thing that knows whether the + * connections still exist, and by the time anybody reads this the node may be gone. Everything the + * registry returns therefore carries {@code observedAt}, and every reader is expected to judge it. + * + * @param actor whose connections + * @param nodeId which process is holding them + * @param channel which feed + * @param connectionCount how many the node had at that moment + * @param observedAt when it looked + */ +public record ConnectionRegistration( + ActorFingerprint actor, + RealtimeNodeId nodeId, + RealtimeChannel channel, + int connectionCount, + Instant observedAt) { + + public ConnectionRegistration { + Objects.requireNonNull(actor, "actor"); + Objects.requireNonNull(nodeId, "nodeId"); + Objects.requireNonNull(channel, "channel"); + Objects.requireNonNull(observedAt, "observedAt"); + if (connectionCount < 0) { + throw new IllegalArgumentException("a connection count cannot be negative"); + } + } + + /** Whether this report is older than the caller is willing to act on. */ + public boolean staleAt(java.time.Duration maximumAge, Instant now) { + Objects.requireNonNull(maximumAge, "maximumAge"); + Objects.requireNonNull(now, "now"); + return !now.isBefore(observedAt.plus(maximumAge)); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/realtime/ConnectionRegistryPort.java b/src/application-core/src/main/java/dev/caskeleton/application/realtime/ConnectionRegistryPort.java new file mode 100644 index 00000000..9fb5a4c6 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/realtime/ConnectionRegistryPort.java @@ -0,0 +1,57 @@ +package dev.caskeleton.application.realtime; + +import java.time.Duration; +import java.time.Instant; +import java.util.List; + +/** + * Where an actor is currently connected, across every node. + * + *

A cache of state owned by other processes, and the port says so rather than leaving each + * caller to discover it. Two expiry mechanisms exist because neither covers the other's case: a TTL + * per entry removes what a node forgot to clean up, and a node heartbeat removes everything a node + * was holding when it died. A TTL alone leaves a dead node's entries live until each one + * individually expires; a heartbeat alone leaves a live node's stale entries for ever. + * + *

Nothing here is authoritative and nothing security-relevant may depend on it. An attacker who + * can stop a node reporting can move this registry's answer, so "is this actor connected" is a + * question for showing a green dot and for deciding where to route a message that will be dropped + * if it is wrong — not for deciding whether a request is legitimate. + * + *

Every implementation is expected to degrade rather than throw. A registry that is unreachable + * should report nothing found, because the caller's fallback — broadcast to the cluster and let the + * holder claim it — is correct and an exception is not. + */ +public interface ConnectionRegistryPort { + + /** + * Record that a node holds connections for an actor. + * + * @param registration what the node observed + * @param timeToLive how long the entry survives without being refreshed + */ + void announce(ConnectionRegistration registration, Duration timeToLive); + + /** Remove one node's entry for an actor. */ + void withdraw(ActorFingerprint actor, RealtimeChannel channel, RealtimeNodeId nodeId); + + /** + * Where an actor is reported connected. + * + * @param now the instant staleness is judged against; entries older than their TTL are omitted + * @return one entry per node, newest first; empty when nothing is known + */ + List locate(ActorFingerprint actor, RealtimeChannel channel, Instant now); + + /** Record that a node is alive. */ + void heartbeat(RealtimeNodeId nodeId, Instant now); + + /** + * Remove everything held by nodes that have stopped reporting. + * + * @param heartbeatTimeout how long a node may be silent before it is treated as gone + * @param now the current instant + * @return the nodes whose entries were removed + */ + List evictSilentNodes(Duration heartbeatTimeout, Instant now); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/realtime/DurableFanoutPort.java b/src/application-core/src/main/java/dev/caskeleton/application/realtime/DurableFanoutPort.java new file mode 100644 index 00000000..dc3f460c --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/realtime/DurableFanoutPort.java @@ -0,0 +1,23 @@ +package dev.caskeleton.application.realtime; + +/** + * At-least-once delivery to whichever node holds the recipient. + * + *

At-least-once is the contract, and saying so is the point of the name. A redeploy, a slow + * consumer or a broker rebalance all redeliver, so a receiver that assumes exactly-once will show a + * user the same thing twice on a perfectly ordinary Tuesday. Deduplication belongs at the receiver, + * keyed on the record's stream and position. + * + *

Publication is not delivery. A successful call means the transport accepted the record, which + * is a statement about the transport and not about any recipient. + */ +public interface DurableFanoutPort { + + /** + * Publish a record. + * + * @param record what to publish + * @throws DurableFanoutUnavailableException when the transport would not accept it + */ + void publish(DurableFanoutRecord record); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/realtime/DurableFanoutRecord.java b/src/application-core/src/main/java/dev/caskeleton/application/realtime/DurableFanoutRecord.java new file mode 100644 index 00000000..24053a8d --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/realtime/DurableFanoutRecord.java @@ -0,0 +1,67 @@ +package dev.caskeleton.application.realtime; + +import java.time.Instant; +import java.util.Objects; +import java.util.Optional; + +/** + * One message published for delivery to whichever node holds its recipient. + * + *

{@code expiresAt} is carried on the record and checked by the receiver, not only by the + * publisher. A durable transport is exactly the one that can hold a message through an outage and + * deliver it afterwards, and for the traffic this carries — a notification, a live update — late is + * indistinguishable from wrong. The receiving node is the only place that knows what time it is + * when the message finally arrives. + * + *

The ordering pair is present or absent together. A stream id without a position cannot be + * deduplicated and a position without a stream cannot be interpreted, so half of one is a caller + * error rather than a partial ordering. + * + * @param channel which feed + * @param partitionKey what the transport should order and route by, usually the recipient + * @param payload the encoded message, as a document rather than raw bytes — a byte array cannot be + * a record component without breaking equality, and every producer here is already serialising + * through the message catalog + * @param streamId the ordering stream, when the message is ordered + * @param position the position within that stream + * @param publishedAt when it was published + * @param expiresAt after which a receiver must drop it + */ +public record DurableFanoutRecord( + RealtimeChannel channel, + String partitionKey, + String payload, + Optional streamId, + Optional position, + Instant publishedAt, + Instant expiresAt) { + + public DurableFanoutRecord { + Objects.requireNonNull(channel, "channel"); + Objects.requireNonNull(partitionKey, "partitionKey"); + Objects.requireNonNull(payload, "payload"); + Objects.requireNonNull(streamId, "streamId"); + Objects.requireNonNull(position, "position"); + Objects.requireNonNull(publishedAt, "publishedAt"); + Objects.requireNonNull(expiresAt, "expiresAt"); + if (partitionKey.isBlank()) { + throw new IllegalArgumentException( + "a blank partition key sends every message to one partition, which serialises the whole" + + " feed behind its slowest recipient"); + } + if (streamId.isPresent() != position.isPresent()) { + throw new IllegalArgumentException( + "a stream id and a position are meaningless apart: one cannot be deduplicated and the" + + " other cannot be interpreted"); + } + if (!expiresAt.isAfter(publishedAt)) { + throw new IllegalArgumentException("a record that expires before it is published is dropped"); + } + } + + /** Whether a receiver must drop this rather than deliver it. */ + public boolean expiredAt(Instant now) { + Objects.requireNonNull(now, "now"); + return !now.isBefore(expiresAt); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/realtime/DurableFanoutUnavailableException.java b/src/application-core/src/main/java/dev/caskeleton/application/realtime/DurableFanoutUnavailableException.java new file mode 100644 index 00000000..ca6b1e14 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/realtime/DurableFanoutUnavailableException.java @@ -0,0 +1,30 @@ +package dev.caskeleton.application.realtime; + +import java.util.Objects; + +/** + * The durable transport would not accept a record. + * + *

Distinct from a delivery failure, which this port cannot observe. It means the message was not + * published, so the caller still owns it — retrying, dropping it, or falling back to a best-effort + * publish are all defensible and the caller is the only one that knows which. + * + *

The channel is named and the payload is not: the payload is the user's data and this exception + * reaches logs. + */ +public final class DurableFanoutUnavailableException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + private final transient RealtimeChannel channel; + + public DurableFanoutUnavailableException(RealtimeChannel channel, Throwable cause) { + super("the durable transport did not accept a record for channel " + channel.value(), cause); + this.channel = Objects.requireNonNull(channel, "channel"); + } + + /** Which channel. */ + public RealtimeChannel channel() { + return channel; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/realtime/EphemeralFanoutPort.java b/src/application-core/src/main/java/dev/caskeleton/application/realtime/EphemeralFanoutPort.java new file mode 100644 index 00000000..a640d8ea --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/realtime/EphemeralFanoutPort.java @@ -0,0 +1,39 @@ +package dev.caskeleton.application.realtime; + +import java.util.function.BiConsumer; + +/** + * Best-effort delivery to every node listening on a channel. + * + *

Ephemeral is the whole contract and it is stated in the name because the alternative is that + * somebody assumes otherwise. A message published here reaches the nodes that are subscribed at + * that instant and nobody else: a node that is restarting misses it, a subscriber that reconnects a + * second later misses it, and nothing is retained, retried or acknowledged. There is no replay. + * + *

That is the right shape for presence updates, typing indicators and cache invalidations — + * things whose next update supersedes the one that was lost. It is the wrong shape for anything a + * user is told happened, and {@link DurableFanoutPort} exists for those. + */ +public interface EphemeralFanoutPort { + + /** + * Publish to everyone currently listening. + * + * @param channel where to publish + * @param payload the encoded message + * @return how many subscribers the transport reported, which is a diagnostic and not a delivery + * guarantee — the count is what the broker saw, not what any node processed + */ + long publish(RealtimeChannel channel, byte[] payload); + + /** + * Listen on a channel. + * + * @param channel what to listen to + * @param listener receives the channel name and the payload; must not block, because it runs on + * the transport's delivery thread and a slow listener stalls every other subscription sharing + * it + */ + FanoutSubscription subscribe( + RealtimeChannel channel, BiConsumer listener); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/realtime/FanoutSubscription.java b/src/application-core/src/main/java/dev/caskeleton/application/realtime/FanoutSubscription.java new file mode 100644 index 00000000..d75b244e --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/realtime/FanoutSubscription.java @@ -0,0 +1,19 @@ +package dev.caskeleton.application.realtime; + +/** + * A live subscription to a channel. + * + *

{@link AutoCloseable} because the subscription holds a connection, and one that is never + * closed is a connection that is never returned. A node that opens one per stream and closes none + * runs out of Redis connections at whatever its stream churn happens to be, which looks like Redis + * being unavailable and is not. + */ +public interface FanoutSubscription extends AutoCloseable { + + /** Whether the subscription is still delivering. */ + boolean active(); + + /** Stop delivering and release the connection. Idempotent. */ + @Override + void close(); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/realtime/LiveEventReplayPort.java b/src/application-core/src/main/java/dev/caskeleton/application/realtime/LiveEventReplayPort.java new file mode 100644 index 00000000..741b0fa6 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/realtime/LiveEventReplayPort.java @@ -0,0 +1,31 @@ +package dev.caskeleton.application.realtime; + +import java.util.List; + +/** + * Retained event history, for a client that reconnects and wants to continue. + * + *

The history lives wherever the messaging platform keeps it, and this port is the only way a + * transport adapter reaches it. That boundary is the point: a web or websocket module that kept its + * own copy would have its own retention, its own eviction and its own opinion about ordering, and + * the two would diverge silently because both would look plausible. + * + *

{@link #window} exists so a caller can distinguish "your cursor is too old" from "there is + * nothing new", which {@link #replayAfter} returning an empty list cannot. + */ +public interface LiveEventReplayPort { + + /** What the stream still holds. */ + ReplayWindow window(String streamId); + + /** + * Events after a position. + * + * @param streamId which stream + * @param afterPosition the last position the client already has; 0 for the beginning + * @param limit the most to return, so one call cannot load a whole stream into memory + * @return the events in ascending position order, possibly empty + * @throws ReplayCursorUnavailableException when the position is older than what is retained + */ + List replayAfter(String streamId, long afterPosition, int limit); +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/realtime/RealtimeChannel.java b/src/application-core/src/main/java/dev/caskeleton/application/realtime/RealtimeChannel.java new file mode 100644 index 00000000..8401ddfb --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/realtime/RealtimeChannel.java @@ -0,0 +1,26 @@ +package dev.caskeleton.application.realtime; + +import java.util.Objects; +import java.util.regex.Pattern; + +/** + * A logical channel: which feed a registration, a fan-out or a replay belongs to. + * + *

Kept separate from the transport's own endpoint or destination names so that a change to a URL + * is not a change to a Redis key or a broker topic. The two drift on purpose — a path can be + * renamed for a client's benefit without invalidating every entry a cluster is holding. + * + * @param value the channel name + */ +public record RealtimeChannel(String value) { + + private static final Pattern GRAMMAR = Pattern.compile("[a-z0-9][a-z0-9._-]{0,63}"); + + public RealtimeChannel { + Objects.requireNonNull(value, "value"); + if (!GRAMMAR.matcher(value).matches()) { + throw new IllegalArgumentException( + "a channel is 1..64 lowercase characters of [a-z0-9._-] starting alphanumeric: " + value); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/realtime/RealtimeNodeId.java b/src/application-core/src/main/java/dev/caskeleton/application/realtime/RealtimeNodeId.java new file mode 100644 index 00000000..13b1e959 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/realtime/RealtimeNodeId.java @@ -0,0 +1,26 @@ +package dev.caskeleton.application.realtime; + +import java.util.Objects; +import java.util.regex.Pattern; + +/** + * Which process reported something. + * + *

Constrained because it is both a datastore key component and a metric tag. An unconstrained + * node id is an unbounded-cardinality tag on every metric that carries it, and in a deployment that + * names nodes after pods that is one new tag value per restart. + * + * @param value the node identity + */ +public record RealtimeNodeId(String value) { + + private static final Pattern GRAMMAR = Pattern.compile("[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}"); + + public RealtimeNodeId { + Objects.requireNonNull(value, "value"); + if (!GRAMMAR.matcher(value).matches()) { + throw new IllegalArgumentException( + "a node id is 1..64 characters of [a-zA-Z0-9._-] starting alphanumeric: " + value); + } + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/realtime/ReplayCursorUnavailableException.java b/src/application-core/src/main/java/dev/caskeleton/application/realtime/ReplayCursorUnavailableException.java new file mode 100644 index 00000000..23375da7 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/realtime/ReplayCursorUnavailableException.java @@ -0,0 +1,37 @@ +package dev.caskeleton.application.realtime; + +import java.util.Objects; + +/** + * A client's position is older than the retained history. + * + *

Thrown rather than served from the oldest available position. Serving would deliver a stream + * whose positions are contiguous from where the replay started, so the client has no way to see the + * hole in the middle — and a client that cannot see a gap does not resnapshot, which is the one + * thing that would fix it. + * + * @see ReplayWindow#canResumeAfter(long) + */ +public final class ReplayCursorUnavailableException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + private final transient String streamId; + private final transient long requestedPosition; + + public ReplayCursorUnavailableException(String streamId, long requestedPosition) { + super("the requested position is older than the retained history; a resnapshot is required"); + this.streamId = Objects.requireNonNull(streamId, "streamId"); + this.requestedPosition = requestedPosition; + } + + /** Which stream. */ + public String streamId() { + return streamId; + } + + /** What was asked for. */ + public long requestedPosition() { + return requestedPosition; + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/realtime/ReplayWindow.java b/src/application-core/src/main/java/dev/caskeleton/application/realtime/ReplayWindow.java new file mode 100644 index 00000000..4618172e --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/realtime/ReplayWindow.java @@ -0,0 +1,57 @@ +package dev.caskeleton.application.realtime; + +import java.util.Objects; +import java.util.Optional; + +/** + * What a stream still holds. + * + *

The type exists so that "can this cursor be honoured" is answerable before anybody tries. A + * replay source that only offers "give me everything after N" leaves the caller to infer eviction + * from an empty result — and an empty result also means "nothing new", which is the opposite + * situation and needs the opposite response. + * + * @param streamId which stream + * @param earliestRetained the oldest position still held, absent when the stream holds nothing + * @param latestRetained the newest position held, absent when the stream holds nothing + */ +public record ReplayWindow( + String streamId, Optional earliestRetained, Optional latestRetained) { + + public ReplayWindow { + Objects.requireNonNull(streamId, "streamId"); + Objects.requireNonNull(earliestRetained, "earliestRetained"); + Objects.requireNonNull(latestRetained, "latestRetained"); + if (earliestRetained.isPresent() != latestRetained.isPresent()) { + throw new IllegalArgumentException( + "a window has both bounds or neither; one alone describes no range"); + } + if (earliestRetained.isPresent() && earliestRetained.get() > latestRetained.get()) { + throw new IllegalArgumentException("a window cannot start after it ends"); + } + } + + /** A stream that holds nothing. */ + public static ReplayWindow empty(String streamId) { + return new ReplayWindow(streamId, Optional.empty(), Optional.empty()); + } + + /** + * Whether a client asking to continue after {@code position} can be served. + * + *

False for a position older than what is retained, which must produce a resnapshot rather + * than a replay from the oldest available: the positions after the hole are contiguous, so + * nothing in the delivered data would say events are missing. + * + *

True for a position at or beyond the newest retained — the client is simply up to date, and + * that is a live subscription with no replay rather than a failure. + */ + public boolean canResumeAfter(long position) { + if (earliestRetained.isEmpty()) { + // Nothing retained at all. Only a client that is already current can be served, and only + // because there is nothing to send it. + return position >= 0; + } + return position + 1 >= earliestRetained.get(); + } +} diff --git a/src/application-core/src/main/java/dev/caskeleton/application/realtime/ReplayedEvent.java b/src/application-core/src/main/java/dev/caskeleton/application/realtime/ReplayedEvent.java new file mode 100644 index 00000000..e843cae7 --- /dev/null +++ b/src/application-core/src/main/java/dev/caskeleton/application/realtime/ReplayedEvent.java @@ -0,0 +1,33 @@ +package dev.caskeleton.application.realtime; + +import java.time.Instant; +import java.util.Objects; + +/** + * One retained event, at the position it occupies in its stream. + * + *

Positions count from 1 so that an unset field cannot pass for the first event. A client + * resuming from position 0 and one resuming from "wherever the stream starts" are different + * requests, and a zero that means both is how a resume silently re-reads history the client already + * had. + * + * @param streamId which stream + * @param position where in it + * @param payload the encoded event + * @param occurredAt when it happened + */ +public record ReplayedEvent(String streamId, long position, String payload, Instant occurredAt) { + + public ReplayedEvent { + Objects.requireNonNull(streamId, "streamId"); + Objects.requireNonNull(payload, "payload"); + Objects.requireNonNull(occurredAt, "occurredAt"); + if (streamId.isBlank()) { + throw new IllegalArgumentException("a stream must be named"); + } + if (position < 1) { + throw new IllegalArgumentException( + "stream positions count from 1, so an unset field cannot pass for the first event"); + } + } +} diff --git a/src/application-core/src/test/java/dev/caskeleton/application/operation/InMemoryDurableOperationStore.java b/src/application-core/src/test/java/dev/caskeleton/application/operation/InMemoryDurableOperationStore.java new file mode 100644 index 00000000..4d60d67c --- /dev/null +++ b/src/application-core/src/test/java/dev/caskeleton/application/operation/InMemoryDurableOperationStore.java @@ -0,0 +1,306 @@ +package dev.caskeleton.application.operation; + +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +/** + * A store with the semantics the JPA adapter must also have. + * + *

Written to the same rules rather than to whatever makes the tests pass: a submission scope + * that already exists returns the existing operation, a lease is checked on every write, and a + * terminal operation is never reopened. A fake that was laxer than the adapter would let the use + * case tests certify behaviour the real store refuses. + */ +final class InMemoryDurableOperationStore implements DurableOperationStorePort { + + private record Scope(String tenant, String principal, String operationName, String hash) {} + + private final Map byId = new ConcurrentHashMap<>(); + private final Map byScope = new ConcurrentHashMap<>(); + private final Map payloads = new ConcurrentHashMap<>(); + + @Override + public DurableOperation submit(DurableOperationSubmission submission) { + Scope scope = + new Scope( + submission.tenantId() == null ? "" : submission.tenantId(), + submission.principal(), + submission.operationName(), + submission.fingerprint().hex()); + DurableOperationId existing = byScope.putIfAbsent(scope, submission.operationId()); + if (existing != null) { + return byId.get(existing); + } + DurableOperation operation = + new DurableOperation( + submission.operationId(), + submission.operationName(), + submission.principal(), + submission.tenantId(), + DurableOperationState.PENDING, + submission.submittedAt(), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty(), + Optional.empty(), + submission.expiresAt()); + byId.put(submission.operationId(), operation); + payloads.put(submission.operationId(), submission.payload()); + return operation; + } + + @Override + public Optional find( + DurableOperationId operationId, String principal, Instant now) { + return Optional.ofNullable(byId.get(operationId)) + .filter(operation -> operation.principal().equals(principal)); + } + + @Override + public Optional claimNext( + String workerId, Duration leaseDuration, Instant now) { + return byId.values().stream() + .filter( + operation -> + operation.state() == DurableOperationState.PENDING + || (operation.state() == DurableOperationState.RUNNING + && operation.leaseExpiredAt(now))) + .filter(operation -> now.isBefore(operation.expiresAt())) + .min(Comparator.comparing(DurableOperation::submittedAt)) + .map( + operation -> { + DurableOperation claimed = + replace( + operation, + DurableOperationState.RUNNING, + Optional.of(operation.startedAt().orElse(now)), + Optional.empty(), + operation.progress(), + operation.resultReference(), + Optional.empty(), + Optional.of(new OperationLease(workerId, now, now.plus(leaseDuration)))); + byId.put(claimed.operationId(), claimed); + return new DurableOperationSubmission( + claimed.operationId(), + claimed.operationName(), + claimed.principal(), + claimed.tenantId(), + new dev.caskeleton.application.idempotency.RequestFingerprint("0".repeat(64)), + payloads.getOrDefault(claimed.operationId(), "{}"), + claimed.submittedAt(), + Duration.between(claimed.submittedAt(), claimed.expiresAt())); + }); + } + + @Override + public boolean heartbeat( + DurableOperationId operationId, String workerId, Duration leaseDuration, Instant now) { + DurableOperation operation = byId.get(operationId); + if (!holdsLease(operation, workerId, now)) { + return false; + } + byId.put( + operationId, + replace( + operation, + operation.state(), + operation.startedAt(), + operation.completedAt(), + operation.progress(), + operation.resultReference(), + Optional.empty(), + operation.lease().map(lease -> lease.extendedTo(now.plus(leaseDuration))))); + return true; + } + + @Override + public boolean reportProgress( + DurableOperationId operationId, String workerId, OperationProgressSnapshot progress) { + DurableOperation operation = byId.get(operationId); + if (operation == null || !ownedBy(operation, workerId)) { + return false; + } + byId.put( + operationId, + replace( + operation, + operation.state(), + operation.startedAt(), + operation.completedAt(), + Optional.of(progress), + operation.resultReference(), + Optional.empty(), + operation.lease())); + return true; + } + + @Override + public boolean succeed( + DurableOperationId operationId, + String workerId, + String resultReference, + Instant completedAt) { + DurableOperation operation = byId.get(operationId); + if (operation == null || !ownedBy(operation, workerId)) { + return false; + } + byId.put( + operationId, + replace( + operation, + DurableOperationState.SUCCEEDED, + operation.startedAt(), + Optional.of(completedAt), + operation.progress(), + Optional.of(resultReference), + Optional.empty(), + Optional.empty())); + return true; + } + + @Override + public boolean fail( + DurableOperationId operationId, + String workerId, + OperationFailure failure, + Instant completedAt) { + DurableOperation operation = byId.get(operationId); + if (operation == null || !ownedBy(operation, workerId)) { + return false; + } + byId.put( + operationId, + replace( + operation, + DurableOperationState.FAILED, + operation.startedAt(), + Optional.of(completedAt), + operation.progress(), + Optional.empty(), + Optional.of(failure), + Optional.empty())); + return true; + } + + @Override + public boolean cancel(DurableOperationId operationId, String principal, Instant canceledAt) { + DurableOperation operation = byId.get(operationId); + if (operation == null + || !operation.principal().equals(principal) + || !operation.state().cancellable()) { + return false; + } + byId.put( + operationId, + replace( + operation, + DurableOperationState.CANCELED, + operation.startedAt(), + Optional.of(canceledAt), + operation.progress(), + operation.resultReference(), + Optional.empty(), + Optional.empty())); + return true; + } + + @Override + public List reclaimExpiredLeases(Instant now) { + List reclaimed = new ArrayList<>(); + byId.forEach( + (id, operation) -> { + if (operation.state() == DurableOperationState.RUNNING && operation.leaseExpiredAt(now)) { + byId.put( + id, + replace( + operation, + DurableOperationState.PENDING, + operation.startedAt(), + Optional.empty(), + operation.progress(), + operation.resultReference(), + Optional.empty(), + Optional.empty())); + reclaimed.add(id); + } + }); + return List.copyOf(reclaimed); + } + + @Override + public List expireStaleOperations(Instant now) { + List expired = new ArrayList<>(); + byId.forEach( + (id, operation) -> { + if (!operation.state().terminal() && !now.isBefore(operation.expiresAt())) { + byId.put( + id, + replace( + operation, + DurableOperationState.EXPIRED, + operation.startedAt(), + Optional.of(now), + operation.progress(), + operation.resultReference(), + Optional.empty(), + Optional.empty())); + expired.add(id); + } + }); + return List.copyOf(expired); + } + + /** How many operations exist. */ + int size() { + return byId.size(); + } + + /** Drops everything, standing in for a rolled-back transaction. */ + void clear() { + byId.clear(); + byScope.clear(); + payloads.clear(); + } + + private static boolean holdsLease(DurableOperation operation, String workerId, Instant now) { + return operation != null && ownedBy(operation, workerId) && !operation.leaseExpiredAt(now); + } + + private static boolean ownedBy(DurableOperation operation, String workerId) { + return operation.state() == DurableOperationState.RUNNING + && operation.lease().map(lease -> lease.workerId().equals(workerId)).orElse(false); + } + + private static DurableOperation replace( + DurableOperation operation, + DurableOperationState state, + Optional startedAt, + Optional completedAt, + Optional progress, + Optional resultReference, + Optional failure, + Optional lease) { + return new DurableOperation( + operation.operationId(), + operation.operationName(), + operation.principal(), + operation.tenantId(), + state, + operation.submittedAt(), + startedAt, + completedAt, + progress, + resultReference, + failure, + lease, + operation.expiresAt()); + } +} diff --git a/src/application-core/src/test/java/dev/caskeleton/application/operation/SubmitDurableOperationUseCaseTest.java b/src/application-core/src/test/java/dev/caskeleton/application/operation/SubmitDurableOperationUseCaseTest.java new file mode 100644 index 00000000..96463c97 --- /dev/null +++ b/src/application-core/src/test/java/dev/caskeleton/application/operation/SubmitDurableOperationUseCaseTest.java @@ -0,0 +1,236 @@ +package dev.caskeleton.application.operation; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import dev.caskeleton.application.idempotency.RequestFingerprint; +import dev.caskeleton.application.outbox.NewOutboxEvent; +import dev.caskeleton.application.outbox.OutboxAppendPort; +import dev.caskeleton.application.transaction.TransactionPort; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Supplier; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * The two writes that must not come apart. + * + *

Accepting long-running work means recording the operation and recording the intent to publish + * it. Either one alone is a specific outage: a published message with no row is work nobody is + * tracking, and a row with no message is an operation that sits PENDING for ever. These cases are + * each one way the pair could split. + */ +class SubmitDurableOperationUseCaseTest { + + private static final Instant NOW = Instant.parse("2026-08-25T10:00:00Z"); + private static final DurableOperationId ID = new DurableOperationId("op-1"); + + private final InMemoryDurableOperationStore operations = new InMemoryDurableOperationStore(); + private final AtomicBoolean insideTransaction = new AtomicBoolean(); + private final RecordingOutbox outbox = new RecordingOutbox(insideTransaction); + private final RollbackAwareTransactions transactions = + new RollbackAwareTransactions(operations, outbox, insideTransaction); + private final SubmitDurableOperationUseCase useCase = + new SubmitDurableOperationUseCase( + operations, outbox, transactions, Clock.fixed(NOW, ZoneOffset.UTC)); + + private static OperationCommand command(DurableOperationId id, String hash) { + return new OperationCommand( + id, + "reports.generate", + "alice", + null, + new RequestFingerprint(hash), + "{\"range\":\"2026-08\"}", + "ReportRequested", + Duration.ofHours(6)); + } + + @Test + @DisplayName("an accepted submission writes both the operation and the outbox event") + void submissionWritesBoth() { + OperationAcceptance acceptance = useCase.submit(command(ID, "a".repeat(64))); + + assertThat(acceptance.operationId()).isEqualTo(ID); + assertThat(acceptance.alreadyAccepted()).isFalse(); + assertThat(operations.size()).isEqualTo(1); + assertThat(outbox.appended).hasSize(1); + } + + @Test + @DisplayName("both writes happen inside one transaction") + void bothWritesShareOneTransaction() { + useCase.submit(command(ID, "a".repeat(64))); + + // Not an implementation detail. Outside one transaction the two writes can commit + // independently, and every failure mode this use case exists to prevent comes back. + assertThat(outbox.appendedOutsideTransaction).isEmpty(); + assertThat(transactions.independentCommits).isEmpty(); + assertThat(transactions.writeTransactions).isEqualTo(1); + } + + @Test + @DisplayName("a rollback leaves neither an operation nor an outbox event") + void rollbackLeavesNothing() { + outbox.failNext = true; + + assertThatThrownBy(() -> useCase.submit(command(ID, "a".repeat(64)))) + .isInstanceOf(IllegalStateException.class); + + assertThat(operations.size()).isZero(); + assertThat(outbox.appended).isEmpty(); + } + + @Test + @DisplayName("an identical resubmission returns the first operation and publishes nothing new") + void resubmissionReusesTheFirstOperation() { + useCase.submit(command(ID, "a".repeat(64))); + + OperationAcceptance repeat = + useCase.submit(command(new DurableOperationId("op-2"), "a".repeat(64))); + + // The client is pointed at the work that is actually running. Starting a second operation + // would run the same report twice and hand back an id for the copy. + assertThat(repeat.operationId()).isEqualTo(ID); + assertThat(repeat.alreadyAccepted()).isTrue(); + assertThat(operations.size()).isEqualTo(1); + assertThat(outbox.appended).hasSize(1); + } + + @Test + @DisplayName("a different request under the same principal is a different operation") + void differentRequestsAreDifferentOperations() { + useCase.submit(command(ID, "a".repeat(64))); + + OperationAcceptance second = + useCase.submit(command(new DurableOperationId("op-2"), "b".repeat(64))); + + assertThat(second.alreadyAccepted()).isFalse(); + assertThat(operations.size()).isEqualTo(2); + assertThat(outbox.appended).hasSize(2); + } + + @Test + @DisplayName("the outbox event carries the operation id as its routing key and dedup key") + void outboxEventIsAnchoredToTheOperation() { + useCase.submit(command(ID, "a".repeat(64))); + + NewOutboxEvent event = outbox.appended.get(0); + + // A worker that receives the message twice — which the relay's at-least-once delivery makes + // ordinary — must be able to tell it is the same work. The operation id is what says so. + assertThat(event.aggregateId()).isEqualTo("op-1"); + assertThat(event.idempotencyKey()).isEqualTo("op-1"); + assertThat(event.eventType()).isEqualTo("ReportRequested"); + } + + @Test + @DisplayName("a command with no event type is refused rather than accepted into silence") + void missingEventTypeIsRefused() { + assertThatThrownBy( + () -> + new OperationCommand( + ID, + "reports.generate", + "alice", + null, + new RequestFingerprint("a".repeat(64)), + "{}", + " ", + Duration.ofHours(1))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("reaches no worker"); + } + + private static final class RecordingOutbox implements OutboxAppendPort { + + private final List appended = new ArrayList<>(); + private final List appendedOutsideTransaction = new ArrayList<>(); + private final AtomicBoolean insideTransaction; + private boolean failNext; + + private RecordingOutbox(AtomicBoolean insideTransaction) { + this.insideTransaction = insideTransaction; + } + + @Override + public void append(NewOutboxEvent event) { + if (!insideTransaction.get()) { + // The port's own contract: appending outside the caller's write transaction is silent + // event loss, and it is invisible to any assertion about what ended up in the list. + appendedOutsideTransaction.add(event.eventId()); + } + if (failNext) { + failNext = false; + throw new IllegalStateException("the outbox write failed"); + } + appended.add(event); + } + } + + /** + * A transaction port that actually rolls back. + * + *

A port that simply runs the action would let {@link #rollbackLeavesNothing} pass for an + * implementation with no transaction at all — the exact defect the test is looking for. + */ + private static final class RollbackAwareTransactions implements TransactionPort { + + private final InMemoryDurableOperationStore operations; + private final RecordingOutbox outbox; + private final AtomicBoolean insideTransaction; + private final List independentCommits = new ArrayList<>(); + private int writeTransactions; + + private RollbackAwareTransactions( + InMemoryDurableOperationStore operations, + RecordingOutbox outbox, + AtomicBoolean insideTransaction) { + this.operations = operations; + this.outbox = outbox; + this.insideTransaction = insideTransaction; + } + + @Override + public T inWrite(Supplier action) { + writeTransactions++; + insideTransaction.set(true); + int outboxMark = outbox.appended.size(); + try { + return action.get(); + } catch (RuntimeException rollback) { + operations.clear(); + while (outbox.appended.size() > outboxMark) { + outbox.appended.remove(outbox.appended.size() - 1); + } + throw rollback; + } finally { + insideTransaction.set(false); + } + } + + @Override + public T inRootWrite(Supplier action) { + return inWrite(action); + } + + @Override + public T inRead(Supplier action) { + return action.get(); + } + + @Override + public T inNew(Supplier action) { + // Deliberately not delegating to inWrite: a REQUIRES_NEW transaction commits independently, + // which is the one thing this submission must never do. + independentCommits.add("inNew"); + return action.get(); + } + } +} diff --git a/src/build.gradle b/src/build.gradle index 0a87ba2b..dc016e58 100644 --- a/src/build.gradle +++ b/src/build.gradle @@ -10,7 +10,7 @@ import org.gradle.api.tasks.bundling.Jar import java.security.MessageDigest plugins { - id 'org.springframework.boot' version '4.0.0' apply false + id 'org.springframework.boot' version '4.0.8' apply false id 'io.spring.dependency-management' version '1.1.6' apply false // feature-static-analysis-quality-contract — static analysis / code quality baseline. id 'com.diffplug.spotless' version '8.6.0' apply false // D1 formatter (google-java-format) diff --git a/src/domain-core/gradle.lockfile b/src/domain-core/gradle.lockfile index 599ff921..e2c95854 100644 --- a/src/domain-core/gradle.lockfile +++ b/src/domain-core/gradle.lockfile @@ -1,7 +1,7 @@ # This is a Gradle generated file for dependency locking. # Manual edits can break the build and are not advised. # This file is expected to be part of source control. -com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor +com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,testAnnotationProcessor com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs com.github.spotbugs:spotbugs:4.10.2=spotbugs @@ -33,7 +33,7 @@ info.picocli:picocli:4.7.7=checkstyle io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor -jaxen:jaxen:2.0.0=spotbugs +jaxen:jaxen:2.0.6=spotbugs net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs org.antlr:antlr4-runtime:4.13.2=checkstyle @@ -43,30 +43,30 @@ org.apache.commons:commons-text:1.15.0=spotbugs org.apache.commons:commons-text:1.3=checkstyle org.apache.httpcomponents:httpclient:4.5.13=checkstyle org.apache.httpcomponents:httpcore:4.4.16=checkstyle -org.apache.logging.log4j:log4j-api:2.25.2=spotbugs -org.apache.logging.log4j:log4j-core:2.25.2=spotbugs +org.apache.logging.log4j:log4j-api:2.25.5=spotbugs +org.apache.logging.log4j:log4j-core:2.25.5=spotbugs org.apache.maven.doxia:doxia-core:1.12.0=checkstyle org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle org.apache.xbean:xbean-reflect:3.7=checkstyle org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath -org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath +org.assertj:assertj-core:3.27.7=testCompileClasspath,testRuntimeClasspath org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle org.codehaus.plexus:plexus-utils:3.3.0=checkstyle org.dom4j:dom4j:2.2.0=spotbugs org.javassist:javassist:3.28.0-GA=checkstyle -org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,testAnnotationProcessor,testCompileClasspath -org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath -org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-engine:6.0.1=testRuntimeClasspath -org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath -org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath +org.jspecify:jspecify:1.0.1=annotationProcessor,checkstyle,testAnnotationProcessor,testCompileClasspath +org.junit.jupiter:junit-jupiter-api:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.3=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.3=testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.3=testRuntimeClasspath +org.junit:junit-bom:6.0.3=testCompileClasspath,testRuntimeClasspath org.junit:junit-bom:6.1.0=spotbugs org.mockito:mockito-core:5.20.0=mockitoAgent org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath @@ -77,7 +77,8 @@ org.ow2.asm:asm-util:9.10.1=spotbugs org.ow2.asm:asm:9.10.1=spotbugs org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor org.reflections:reflections:0.10.2=checkstyle -org.slf4j:slf4j-api:2.0.17=spotbugs,spotbugsSlf4j -org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j +org.slf4j:slf4j-api:2.0.18=spotbugs,spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.18=checkstyle org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs empty=compileClasspath,runtimeClasspath diff --git a/src/messaging/messaging-admin-api/gradle.lockfile b/src/messaging/messaging-admin-api/gradle.lockfile index 599ff921..e2c95854 100644 --- a/src/messaging/messaging-admin-api/gradle.lockfile +++ b/src/messaging/messaging-admin-api/gradle.lockfile @@ -1,7 +1,7 @@ # This is a Gradle generated file for dependency locking. # Manual edits can break the build and are not advised. # This file is expected to be part of source control. -com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor +com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,testAnnotationProcessor com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs com.github.spotbugs:spotbugs:4.10.2=spotbugs @@ -33,7 +33,7 @@ info.picocli:picocli:4.7.7=checkstyle io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor -jaxen:jaxen:2.0.0=spotbugs +jaxen:jaxen:2.0.6=spotbugs net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs org.antlr:antlr4-runtime:4.13.2=checkstyle @@ -43,30 +43,30 @@ org.apache.commons:commons-text:1.15.0=spotbugs org.apache.commons:commons-text:1.3=checkstyle org.apache.httpcomponents:httpclient:4.5.13=checkstyle org.apache.httpcomponents:httpcore:4.4.16=checkstyle -org.apache.logging.log4j:log4j-api:2.25.2=spotbugs -org.apache.logging.log4j:log4j-core:2.25.2=spotbugs +org.apache.logging.log4j:log4j-api:2.25.5=spotbugs +org.apache.logging.log4j:log4j-core:2.25.5=spotbugs org.apache.maven.doxia:doxia-core:1.12.0=checkstyle org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle org.apache.xbean:xbean-reflect:3.7=checkstyle org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath -org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath +org.assertj:assertj-core:3.27.7=testCompileClasspath,testRuntimeClasspath org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle org.codehaus.plexus:plexus-utils:3.3.0=checkstyle org.dom4j:dom4j:2.2.0=spotbugs org.javassist:javassist:3.28.0-GA=checkstyle -org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,testAnnotationProcessor,testCompileClasspath -org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath -org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-engine:6.0.1=testRuntimeClasspath -org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath -org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath +org.jspecify:jspecify:1.0.1=annotationProcessor,checkstyle,testAnnotationProcessor,testCompileClasspath +org.junit.jupiter:junit-jupiter-api:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.3=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.3=testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.3=testRuntimeClasspath +org.junit:junit-bom:6.0.3=testCompileClasspath,testRuntimeClasspath org.junit:junit-bom:6.1.0=spotbugs org.mockito:mockito-core:5.20.0=mockitoAgent org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath @@ -77,7 +77,8 @@ org.ow2.asm:asm-util:9.10.1=spotbugs org.ow2.asm:asm:9.10.1=spotbugs org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor org.reflections:reflections:0.10.2=checkstyle -org.slf4j:slf4j-api:2.0.17=spotbugs,spotbugsSlf4j -org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j +org.slf4j:slf4j-api:2.0.18=spotbugs,spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.18=checkstyle org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs empty=compileClasspath,runtimeClasspath diff --git a/src/messaging/messaging-admin-runtime/gradle.lockfile b/src/messaging/messaging-admin-runtime/gradle.lockfile index 8a9cedb6..f5ad1bb5 100644 --- a/src/messaging/messaging-admin-runtime/gradle.lockfile +++ b/src/messaging/messaging-admin-runtime/gradle.lockfile @@ -1,7 +1,7 @@ # This is a Gradle generated file for dependency locking. # Manual edits can break the build and are not advised. # This file is expected to be part of source control. -com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor +com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,testAnnotationProcessor com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs com.github.spotbugs:spotbugs:4.10.2=spotbugs @@ -32,11 +32,11 @@ commons-io:commons-io:2.21.0=spotbugs info.picocli:picocli:4.7.7=checkstyle io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor -io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.micrometer:micrometer-core:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-commons:1.16.7=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-core:1.16.7=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-observation:1.16.7=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor -jaxen:jaxen:2.0.0=spotbugs +jaxen:jaxen:2.0.6=spotbugs net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs org.antlr:antlr4-runtime:4.13.2=checkstyle @@ -46,15 +46,15 @@ org.apache.commons:commons-text:1.15.0=spotbugs org.apache.commons:commons-text:1.3=checkstyle org.apache.httpcomponents:httpclient:4.5.13=checkstyle org.apache.httpcomponents:httpcore:4.4.16=checkstyle -org.apache.logging.log4j:log4j-api:2.25.2=spotbugs -org.apache.logging.log4j:log4j-core:2.25.2=spotbugs +org.apache.logging.log4j:log4j-api:2.25.5=spotbugs +org.apache.logging.log4j:log4j-core:2.25.5=spotbugs org.apache.maven.doxia:doxia-core:1.12.0=checkstyle org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle org.apache.xbean:xbean-reflect:3.7=checkstyle org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath -org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath +org.assertj:assertj-core:3.27.7=testCompileClasspath,testRuntimeClasspath org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle @@ -62,15 +62,15 @@ org.codehaus.plexus:plexus-utils:3.3.0=checkstyle org.dom4j:dom4j:2.2.0=spotbugs org.hdrhistogram:HdrHistogram:2.2.2=runtimeClasspath,testRuntimeClasspath org.javassist:javassist:3.28.0-GA=checkstyle -org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath -org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-engine:6.0.1=testRuntimeClasspath -org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath -org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath +org.jspecify:jspecify:1.0.1=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.3=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.3=testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.3=testRuntimeClasspath +org.junit:junit-bom:6.0.3=testCompileClasspath,testRuntimeClasspath org.junit:junit-bom:6.1.0=spotbugs org.latencyutils:LatencyUtils:2.0.3=runtimeClasspath,testRuntimeClasspath org.mockito:mockito-core:5.20.0=mockitoAgent @@ -82,7 +82,8 @@ org.ow2.asm:asm-util:9.10.1=spotbugs org.ow2.asm:asm:9.10.1=spotbugs org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor org.reflections:reflections:0.10.2=checkstyle -org.slf4j:slf4j-api:2.0.17=spotbugs,spotbugsSlf4j -org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j +org.slf4j:slf4j-api:2.0.18=spotbugs,spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.18=checkstyle org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs empty= diff --git a/src/messaging/messaging-claim-check/gradle.lockfile b/src/messaging/messaging-claim-check/gradle.lockfile index 599ff921..e2c95854 100644 --- a/src/messaging/messaging-claim-check/gradle.lockfile +++ b/src/messaging/messaging-claim-check/gradle.lockfile @@ -1,7 +1,7 @@ # This is a Gradle generated file for dependency locking. # Manual edits can break the build and are not advised. # This file is expected to be part of source control. -com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor +com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,testAnnotationProcessor com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs com.github.spotbugs:spotbugs:4.10.2=spotbugs @@ -33,7 +33,7 @@ info.picocli:picocli:4.7.7=checkstyle io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor -jaxen:jaxen:2.0.0=spotbugs +jaxen:jaxen:2.0.6=spotbugs net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs org.antlr:antlr4-runtime:4.13.2=checkstyle @@ -43,30 +43,30 @@ org.apache.commons:commons-text:1.15.0=spotbugs org.apache.commons:commons-text:1.3=checkstyle org.apache.httpcomponents:httpclient:4.5.13=checkstyle org.apache.httpcomponents:httpcore:4.4.16=checkstyle -org.apache.logging.log4j:log4j-api:2.25.2=spotbugs -org.apache.logging.log4j:log4j-core:2.25.2=spotbugs +org.apache.logging.log4j:log4j-api:2.25.5=spotbugs +org.apache.logging.log4j:log4j-core:2.25.5=spotbugs org.apache.maven.doxia:doxia-core:1.12.0=checkstyle org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle org.apache.xbean:xbean-reflect:3.7=checkstyle org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath -org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath +org.assertj:assertj-core:3.27.7=testCompileClasspath,testRuntimeClasspath org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle org.codehaus.plexus:plexus-utils:3.3.0=checkstyle org.dom4j:dom4j:2.2.0=spotbugs org.javassist:javassist:3.28.0-GA=checkstyle -org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,testAnnotationProcessor,testCompileClasspath -org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath -org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-engine:6.0.1=testRuntimeClasspath -org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath -org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath +org.jspecify:jspecify:1.0.1=annotationProcessor,checkstyle,testAnnotationProcessor,testCompileClasspath +org.junit.jupiter:junit-jupiter-api:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.3=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.3=testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.3=testRuntimeClasspath +org.junit:junit-bom:6.0.3=testCompileClasspath,testRuntimeClasspath org.junit:junit-bom:6.1.0=spotbugs org.mockito:mockito-core:5.20.0=mockitoAgent org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath @@ -77,7 +77,8 @@ org.ow2.asm:asm-util:9.10.1=spotbugs org.ow2.asm:asm:9.10.1=spotbugs org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor org.reflections:reflections:0.10.2=checkstyle -org.slf4j:slf4j-api:2.0.17=spotbugs,spotbugsSlf4j -org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j +org.slf4j:slf4j-api:2.0.18=spotbugs,spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.18=checkstyle org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs empty=compileClasspath,runtimeClasspath diff --git a/src/messaging/messaging-cloudevents/gradle.lockfile b/src/messaging/messaging-cloudevents/gradle.lockfile index fbfe6abf..7aa93f7f 100644 --- a/src/messaging/messaging-cloudevents/gradle.lockfile +++ b/src/messaging/messaging-cloudevents/gradle.lockfile @@ -1,7 +1,7 @@ # This is a Gradle generated file for dependency locking. # Manual edits can break the build and are not advised. # This file is expected to be part of source control. -com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor +com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,testAnnotationProcessor com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs com.github.spotbugs:spotbugs:4.10.2=spotbugs @@ -35,7 +35,7 @@ io.cloudevents:cloudevents-core:4.0.1=compileClasspath,runtimeClasspath,testComp io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor -jaxen:jaxen:2.0.0=spotbugs +jaxen:jaxen:2.0.6=spotbugs net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs org.antlr:antlr4-runtime:4.13.2=checkstyle @@ -45,30 +45,30 @@ org.apache.commons:commons-text:1.15.0=spotbugs org.apache.commons:commons-text:1.3=checkstyle org.apache.httpcomponents:httpclient:4.5.13=checkstyle org.apache.httpcomponents:httpcore:4.4.16=checkstyle -org.apache.logging.log4j:log4j-api:2.25.2=spotbugs -org.apache.logging.log4j:log4j-core:2.25.2=spotbugs +org.apache.logging.log4j:log4j-api:2.25.5=spotbugs +org.apache.logging.log4j:log4j-core:2.25.5=spotbugs org.apache.maven.doxia:doxia-core:1.12.0=checkstyle org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle org.apache.xbean:xbean-reflect:3.7=checkstyle org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath -org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath +org.assertj:assertj-core:3.27.7=testCompileClasspath,testRuntimeClasspath org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle org.codehaus.plexus:plexus-utils:3.3.0=checkstyle org.dom4j:dom4j:2.2.0=spotbugs org.javassist:javassist:3.28.0-GA=checkstyle -org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,testAnnotationProcessor,testCompileClasspath -org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath -org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-engine:6.0.1=testRuntimeClasspath -org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath -org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath +org.jspecify:jspecify:1.0.1=annotationProcessor,checkstyle,testAnnotationProcessor,testCompileClasspath +org.junit.jupiter:junit-jupiter-api:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.3=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.3=testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.3=testRuntimeClasspath +org.junit:junit-bom:6.0.3=testCompileClasspath,testRuntimeClasspath org.junit:junit-bom:6.1.0=spotbugs org.mockito:mockito-core:5.20.0=mockitoAgent org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath @@ -79,7 +79,8 @@ org.ow2.asm:asm-util:9.10.1=spotbugs org.ow2.asm:asm:9.10.1=spotbugs org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor org.reflections:reflections:0.10.2=checkstyle -org.slf4j:slf4j-api:2.0.17=spotbugs,spotbugsSlf4j -org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j +org.slf4j:slf4j-api:2.0.18=spotbugs,spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.18=checkstyle org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs empty= diff --git a/src/messaging/messaging-core-api/gradle.lockfile b/src/messaging/messaging-core-api/gradle.lockfile index 599ff921..e2c95854 100644 --- a/src/messaging/messaging-core-api/gradle.lockfile +++ b/src/messaging/messaging-core-api/gradle.lockfile @@ -1,7 +1,7 @@ # This is a Gradle generated file for dependency locking. # Manual edits can break the build and are not advised. # This file is expected to be part of source control. -com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor +com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,testAnnotationProcessor com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs com.github.spotbugs:spotbugs:4.10.2=spotbugs @@ -33,7 +33,7 @@ info.picocli:picocli:4.7.7=checkstyle io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor -jaxen:jaxen:2.0.0=spotbugs +jaxen:jaxen:2.0.6=spotbugs net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs org.antlr:antlr4-runtime:4.13.2=checkstyle @@ -43,30 +43,30 @@ org.apache.commons:commons-text:1.15.0=spotbugs org.apache.commons:commons-text:1.3=checkstyle org.apache.httpcomponents:httpclient:4.5.13=checkstyle org.apache.httpcomponents:httpcore:4.4.16=checkstyle -org.apache.logging.log4j:log4j-api:2.25.2=spotbugs -org.apache.logging.log4j:log4j-core:2.25.2=spotbugs +org.apache.logging.log4j:log4j-api:2.25.5=spotbugs +org.apache.logging.log4j:log4j-core:2.25.5=spotbugs org.apache.maven.doxia:doxia-core:1.12.0=checkstyle org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle org.apache.xbean:xbean-reflect:3.7=checkstyle org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath -org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath +org.assertj:assertj-core:3.27.7=testCompileClasspath,testRuntimeClasspath org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle org.codehaus.plexus:plexus-utils:3.3.0=checkstyle org.dom4j:dom4j:2.2.0=spotbugs org.javassist:javassist:3.28.0-GA=checkstyle -org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,testAnnotationProcessor,testCompileClasspath -org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath -org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-engine:6.0.1=testRuntimeClasspath -org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath -org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath +org.jspecify:jspecify:1.0.1=annotationProcessor,checkstyle,testAnnotationProcessor,testCompileClasspath +org.junit.jupiter:junit-jupiter-api:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.3=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.3=testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.3=testRuntimeClasspath +org.junit:junit-bom:6.0.3=testCompileClasspath,testRuntimeClasspath org.junit:junit-bom:6.1.0=spotbugs org.mockito:mockito-core:5.20.0=mockitoAgent org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath @@ -77,7 +77,8 @@ org.ow2.asm:asm-util:9.10.1=spotbugs org.ow2.asm:asm:9.10.1=spotbugs org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor org.reflections:reflections:0.10.2=checkstyle -org.slf4j:slf4j-api:2.0.17=spotbugs,spotbugsSlf4j -org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j +org.slf4j:slf4j-api:2.0.18=spotbugs,spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.18=checkstyle org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs empty=compileClasspath,runtimeClasspath diff --git a/src/messaging/messaging-inbox-jdbc-postgresql/gradle.lockfile b/src/messaging/messaging-inbox-jdbc-postgresql/gradle.lockfile index a52b0019..badf14ea 100644 --- a/src/messaging/messaging-inbox-jdbc-postgresql/gradle.lockfile +++ b/src/messaging/messaging-inbox-jdbc-postgresql/gradle.lockfile @@ -1,11 +1,11 @@ # This is a Gradle generated file for dependency locking. # Manual edits can break the build and are not advised. # This file is expected to be part of source control. -com.fasterxml.jackson.core:jackson-annotations:2.20=testCompileClasspath,testRuntimeClasspath -com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor -com.github.docker-java:docker-java-api:3.7.0=testCompileClasspath,testRuntimeClasspath -com.github.docker-java:docker-java-transport-zerodep:3.7.0=testCompileClasspath,testRuntimeClasspath -com.github.docker-java:docker-java-transport:3.7.0=testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-annotations:2.21=testCompileClasspath,testRuntimeClasspath +com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,testAnnotationProcessor +com.github.docker-java:docker-java-api:3.7.1=testCompileClasspath,testRuntimeClasspath +com.github.docker-java:docker-java-transport-zerodep:3.7.1=testCompileClasspath,testRuntimeClasspath +com.github.docker-java:docker-java-transport:3.7.1=testCompileClasspath,testRuntimeClasspath com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs com.github.spotbugs:spotbugs:4.10.2=spotbugs @@ -35,12 +35,12 @@ commons-codec:commons-codec:1.19.0=testCompileClasspath,testRuntimeClasspath commons-collections:commons-collections:3.2.2=checkstyle commons-io:commons-io:2.20.0=testCompileClasspath,testRuntimeClasspath commons-io:commons-io:2.21.0=spotbugs -commons-logging:commons-logging:1.3.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +commons-logging:commons-logging:1.3.6=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath info.picocli:picocli:4.7.7=checkstyle io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor -jaxen:jaxen:2.0.0=spotbugs +jaxen:jaxen:2.0.6=spotbugs net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath net.java.dev.jna:jna:5.18.1=testCompileClasspath,testRuntimeClasspath net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs @@ -52,16 +52,16 @@ org.apache.commons:commons-text:1.15.0=spotbugs org.apache.commons:commons-text:1.3=checkstyle org.apache.httpcomponents:httpclient:4.5.13=checkstyle org.apache.httpcomponents:httpcore:4.4.16=checkstyle -org.apache.logging.log4j:log4j-api:2.25.2=spotbugs -org.apache.logging.log4j:log4j-core:2.25.2=spotbugs +org.apache.logging.log4j:log4j-api:2.25.5=spotbugs +org.apache.logging.log4j:log4j-core:2.25.5=spotbugs org.apache.maven.doxia:doxia-core:1.12.0=checkstyle org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle org.apache.xbean:xbean-reflect:3.7=checkstyle org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath -org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath -org.checkerframework:checker-qual:3.49.5=testRuntimeClasspath +org.assertj:assertj-core:3.27.7=testCompileClasspath,testRuntimeClasspath +org.checkerframework:checker-qual:3.55.1=testRuntimeClasspath org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle @@ -69,15 +69,15 @@ org.codehaus.plexus:plexus-utils:3.3.0=checkstyle org.dom4j:dom4j:2.2.0=spotbugs org.javassist:javassist:3.28.0-GA=checkstyle org.jetbrains:annotations:17.0.0=testCompileClasspath,testRuntimeClasspath -org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath -org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-engine:6.0.1=testRuntimeClasspath -org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath -org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath +org.jspecify:jspecify:1.0.1=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.3=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.3=testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.3=testRuntimeClasspath +org.junit:junit-bom:6.0.3=testCompileClasspath,testRuntimeClasspath org.junit:junit-bom:6.1.0=spotbugs org.mockito:mockito-core:5.20.0=mockitoAgent org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath @@ -87,19 +87,20 @@ org.ow2.asm:asm-tree:9.10.1=spotbugs org.ow2.asm:asm-util:9.10.1=spotbugs org.ow2.asm:asm:9.10.1=spotbugs org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor -org.postgresql:postgresql:42.7.8=testCompileClasspath,testRuntimeClasspath +org.postgresql:postgresql:42.7.13=testCompileClasspath,testRuntimeClasspath org.reflections:reflections:0.10.2=checkstyle org.rnorth.duct-tape:duct-tape:1.0.8=testCompileClasspath,testRuntimeClasspath -org.slf4j:slf4j-api:2.0.17=spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath -org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j -org.springframework:spring-beans:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-core:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-jdbc:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-tx:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.testcontainers:testcontainers-database-commons:2.0.2=testCompileClasspath,testRuntimeClasspath -org.testcontainers:testcontainers-jdbc:2.0.2=testCompileClasspath,testRuntimeClasspath -org.testcontainers:testcontainers-junit-jupiter:2.0.2=testCompileClasspath,testRuntimeClasspath -org.testcontainers:testcontainers-postgresql:2.0.2=testCompileClasspath,testRuntimeClasspath -org.testcontainers:testcontainers:2.0.2=testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-api:2.0.18=spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.18=checkstyle +org.springframework:spring-beans:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-core:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-jdbc:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-tx:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.testcontainers:testcontainers-database-commons:2.0.5=testCompileClasspath,testRuntimeClasspath +org.testcontainers:testcontainers-jdbc:2.0.5=testCompileClasspath,testRuntimeClasspath +org.testcontainers:testcontainers-junit-jupiter:2.0.5=testCompileClasspath,testRuntimeClasspath +org.testcontainers:testcontainers-postgresql:2.0.5=testCompileClasspath,testRuntimeClasspath +org.testcontainers:testcontainers:2.0.5=testCompileClasspath,testRuntimeClasspath org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs empty= diff --git a/src/messaging/messaging-inbox-jdbc-postgresql/src/test/java/dev/caskeleton/messaging/inbox/InboxPostgresIT.java b/src/messaging/messaging-inbox-jdbc-postgresql/src/test/java/dev/caskeleton/messaging/inbox/InboxPostgresIT.java index b4b0c345..a3814181 100644 --- a/src/messaging/messaging-inbox-jdbc-postgresql/src/test/java/dev/caskeleton/messaging/inbox/InboxPostgresIT.java +++ b/src/messaging/messaging-inbox-jdbc-postgresql/src/test/java/dev/caskeleton/messaging/inbox/InboxPostgresIT.java @@ -40,6 +40,9 @@ class InboxPostgresIT { private static final Instant NOW = Instant.parse("2026-08-10T09:15:00Z"); + // Scope-based leak analysis cannot see the owner: @Container: the Testcontainers JUnit extension + // owns this lifecycle. + @SuppressWarnings("resource") @Container private static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer("postgres:16-alpine") diff --git a/src/messaging/messaging-kafka-share-experimental/gradle.lockfile b/src/messaging/messaging-kafka-share-experimental/gradle.lockfile index f5485ac6..cd62c0ed 100644 --- a/src/messaging/messaging-kafka-share-experimental/gradle.lockfile +++ b/src/messaging/messaging-kafka-share-experimental/gradle.lockfile @@ -1,7 +1,8 @@ # This is a Gradle generated file for dependency locking. # Manual edits can break the build and are not advised. # This file is expected to be part of source control. -com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor +at.yawk.lz4:lz4-java:1.10.1=runtimeClasspath,testRuntimeClasspath +com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,testAnnotationProcessor com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor com.github.luben:zstd-jni:1.5.6-10=runtimeClasspath,testRuntimeClasspath com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs @@ -30,15 +31,15 @@ com.puppycrawl.tools:checkstyle:13.5.0=checkstyle commons-beanutils:commons-beanutils:1.11.0=checkstyle commons-collections:commons-collections:3.2.2=checkstyle commons-io:commons-io:2.21.0=spotbugs -commons-logging:commons-logging:1.3.5=runtimeClasspath,testRuntimeClasspath +commons-logging:commons-logging:1.3.6=runtimeClasspath,testRuntimeClasspath info.picocli:picocli:4.7.7=checkstyle io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor -io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.micrometer:micrometer-core:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-commons:1.16.7=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-core:1.16.7=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-observation:1.16.7=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor -jaxen:jaxen:2.0.0=spotbugs +jaxen:jaxen:2.0.6=spotbugs net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs org.antlr:antlr4-runtime:4.13.2=checkstyle @@ -48,16 +49,16 @@ org.apache.commons:commons-text:1.15.0=spotbugs org.apache.commons:commons-text:1.3=checkstyle org.apache.httpcomponents:httpclient:4.5.13=checkstyle org.apache.httpcomponents:httpcore:4.4.16=checkstyle -org.apache.kafka:kafka-clients:4.1.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.apache.logging.log4j:log4j-api:2.25.2=spotbugs -org.apache.logging.log4j:log4j-core:2.25.2=spotbugs +org.apache.kafka:kafka-clients:4.1.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-api:2.25.5=spotbugs +org.apache.logging.log4j:log4j-core:2.25.5=spotbugs org.apache.maven.doxia:doxia-core:1.12.0=checkstyle org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle org.apache.xbean:xbean-reflect:3.7=checkstyle org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath -org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath +org.assertj:assertj-core:3.27.7=testCompileClasspath,testRuntimeClasspath org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle @@ -65,18 +66,17 @@ org.codehaus.plexus:plexus-utils:3.3.0=checkstyle org.dom4j:dom4j:2.2.0=spotbugs org.hdrhistogram:HdrHistogram:2.2.2=runtimeClasspath,testRuntimeClasspath org.javassist:javassist:3.28.0-GA=checkstyle -org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath -org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-engine:6.0.1=testRuntimeClasspath -org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath -org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath +org.jspecify:jspecify:1.0.1=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.3=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.3=testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.3=testRuntimeClasspath +org.junit:junit-bom:6.0.3=testCompileClasspath,testRuntimeClasspath org.junit:junit-bom:6.1.0=spotbugs org.latencyutils:LatencyUtils:2.0.3=runtimeClasspath,testRuntimeClasspath -org.lz4:lz4-java:1.8.0=runtimeClasspath,testRuntimeClasspath org.mockito:mockito-core:5.20.0=mockitoAgent org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath org.ow2.asm:asm-analysis:9.10.1=spotbugs @@ -86,16 +86,17 @@ org.ow2.asm:asm-util:9.10.1=spotbugs org.ow2.asm:asm:9.10.1=spotbugs org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor org.reflections:reflections:0.10.2=checkstyle -org.slf4j:slf4j-api:2.0.17=runtimeClasspath,spotbugs,spotbugsSlf4j,testRuntimeClasspath -org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j -org.springframework.kafka:spring-kafka:4.0.0=runtimeClasspath,testRuntimeClasspath -org.springframework:spring-aop:7.0.1=runtimeClasspath,testRuntimeClasspath -org.springframework:spring-beans:7.0.1=runtimeClasspath,testRuntimeClasspath -org.springframework:spring-context:7.0.1=runtimeClasspath,testRuntimeClasspath -org.springframework:spring-core:7.0.1=runtimeClasspath,testRuntimeClasspath -org.springframework:spring-expression:7.0.1=runtimeClasspath,testRuntimeClasspath -org.springframework:spring-messaging:7.0.1=runtimeClasspath,testRuntimeClasspath -org.springframework:spring-tx:7.0.1=runtimeClasspath,testRuntimeClasspath +org.slf4j:slf4j-api:2.0.18=runtimeClasspath,spotbugs,spotbugsSlf4j,testRuntimeClasspath +org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.18=checkstyle +org.springframework.kafka:spring-kafka:4.0.7=runtimeClasspath,testRuntimeClasspath +org.springframework:spring-aop:7.0.9=runtimeClasspath,testRuntimeClasspath +org.springframework:spring-beans:7.0.9=runtimeClasspath,testRuntimeClasspath +org.springframework:spring-context:7.0.9=runtimeClasspath,testRuntimeClasspath +org.springframework:spring-core:7.0.9=runtimeClasspath,testRuntimeClasspath +org.springframework:spring-expression:7.0.9=runtimeClasspath,testRuntimeClasspath +org.springframework:spring-messaging:7.0.9=runtimeClasspath,testRuntimeClasspath +org.springframework:spring-tx:7.0.9=runtimeClasspath,testRuntimeClasspath org.xerial.snappy:snappy-java:1.1.10.7=runtimeClasspath,testRuntimeClasspath org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs empty= diff --git a/src/messaging/messaging-kafka/gradle.lockfile b/src/messaging/messaging-kafka/gradle.lockfile index 5fbdba69..3bbaee75 100644 --- a/src/messaging/messaging-kafka/gradle.lockfile +++ b/src/messaging/messaging-kafka/gradle.lockfile @@ -1,11 +1,12 @@ # This is a Gradle generated file for dependency locking. # Manual edits can break the build and are not advised. # This file is expected to be part of source control. -com.fasterxml.jackson.core:jackson-annotations:2.20=jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor -com.github.docker-java:docker-java-api:3.7.0=jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -com.github.docker-java:docker-java-transport-zerodep:3.7.0=jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -com.github.docker-java:docker-java-transport:3.7.0=jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +at.yawk.lz4:lz4-java:1.10.1=jmhRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-annotations:2.21=jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor +com.github.docker-java:docker-java-api:3.7.1=jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.github.docker-java:docker-java-transport-zerodep:3.7.1=jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.github.docker-java:docker-java-transport:3.7.1=jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor com.github.luben:zstd-jni:1.5.6-10=jmhRuntimeClasspath,runtimeClasspath,testRuntimeClasspath com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs @@ -36,16 +37,16 @@ commons-codec:commons-codec:1.19.0=jmhCompileClasspath,jmhRuntimeClasspath,testC commons-collections:commons-collections:3.2.2=checkstyle commons-io:commons-io:2.20.0=jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath commons-io:commons-io:2.21.0=spotbugs -commons-logging:commons-logging:1.3.5=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +commons-logging:commons-logging:1.3.6=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath eu.rekawek.toxiproxy:toxiproxy-java:2.1.7=jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath info.picocli:picocli:4.7.7=checkstyle io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor -io.micrometer:micrometer-commons:1.16.0=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.micrometer:micrometer-core:1.16.0=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.micrometer:micrometer-observation:1.16.0=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-commons:1.16.7=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-core:1.16.7=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-observation:1.16.7=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath javax.inject:javax.inject:1=annotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor -jaxen:jaxen:2.0.0=spotbugs +jaxen:jaxen:2.0.6=spotbugs net.bytebuddy:byte-buddy:1.17.8=jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath net.java.dev.jna:jna:5.18.1=jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath net.sf.jopt-simple:jopt-simple:5.0.4=jmhAnnotationProcessor,jmhCompileClasspath,jmhRuntimeClasspath @@ -59,16 +60,16 @@ org.apache.commons:commons-text:1.15.0=spotbugs org.apache.commons:commons-text:1.3=checkstyle org.apache.httpcomponents:httpclient:4.5.13=checkstyle org.apache.httpcomponents:httpcore:4.4.16=checkstyle -org.apache.kafka:kafka-clients:4.1.1=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.apache.logging.log4j:log4j-api:2.25.2=spotbugs -org.apache.logging.log4j:log4j-core:2.25.2=spotbugs +org.apache.kafka:kafka-clients:4.1.2=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-api:2.25.5=spotbugs +org.apache.logging.log4j:log4j-core:2.25.5=spotbugs org.apache.maven.doxia:doxia-core:1.12.0=checkstyle org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle org.apache.xbean:xbean-reflect:3.7=checkstyle org.apiguardian:apiguardian-api:1.1.2=jmhCompileClasspath,testCompileClasspath -org.assertj:assertj-core:3.27.6=jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.assertj:assertj-core:3.27.7=jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle @@ -77,18 +78,17 @@ org.dom4j:dom4j:2.2.0=spotbugs org.hdrhistogram:HdrHistogram:2.2.2=jmhRuntimeClasspath,runtimeClasspath,testRuntimeClasspath org.javassist:javassist:3.28.0-GA=checkstyle org.jetbrains:annotations:17.0.0=jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,jmhAnnotationProcessor,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-api:6.0.1=jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-engine:6.0.1=jmhRuntimeClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-params:6.0.1=jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter:6.0.1=jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-commons:6.0.1=jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-engine:6.0.1=jmhRuntimeClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-launcher:6.0.1=jmhRuntimeClasspath,testRuntimeClasspath -org.junit:junit-bom:6.0.1=jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.jspecify:jspecify:1.0.1=annotationProcessor,checkstyle,compileClasspath,jmhAnnotationProcessor,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:6.0.3=jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.3=jmhRuntimeClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.3=jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.3=jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.3=jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.3=jmhRuntimeClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.3=jmhRuntimeClasspath,testRuntimeClasspath +org.junit:junit-bom:6.0.3=jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.junit:junit-bom:6.1.0=spotbugs org.latencyutils:LatencyUtils:2.0.3=jmhRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.lz4:lz4-java:1.8.0=jmhRuntimeClasspath,runtimeClasspath,testRuntimeClasspath org.mockito:mockito-core:5.20.0=mockitoAgent org.openjdk.jmh:jmh-core:1.37=jmhAnnotationProcessor,jmhCompileClasspath,jmhRuntimeClasspath org.openjdk.jmh:jmh-generator-annprocess:1.37=jmhAnnotationProcessor @@ -101,20 +101,21 @@ org.ow2.asm:asm:9.10.1=spotbugs org.pcollections:pcollections:4.0.1=annotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor org.reflections:reflections:0.10.2=checkstyle org.rnorth.duct-tape:duct-tape:1.0.8=jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.slf4j:slf4j-api:2.0.17=jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath -org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j -org.springframework.kafka:spring-kafka:4.0.0=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-aop:7.0.1=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-beans:7.0.1=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-context:7.0.1=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-core:7.0.1=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-expression:7.0.1=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-messaging:7.0.1=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-tx:7.0.1=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.testcontainers:testcontainers-junit-jupiter:2.0.2=jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.testcontainers:testcontainers-kafka:2.0.2=jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.testcontainers:testcontainers-toxiproxy:2.0.2=jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.testcontainers:testcontainers:2.0.2=jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-api:2.0.18=jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.18=checkstyle +org.springframework.kafka:spring-kafka:4.0.7=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-aop:7.0.9=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-beans:7.0.9=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-context:7.0.9=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-core:7.0.9=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-expression:7.0.9=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-messaging:7.0.9=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-tx:7.0.9=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.testcontainers:testcontainers-junit-jupiter:2.0.5=jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.testcontainers:testcontainers-kafka:2.0.5=jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.testcontainers:testcontainers-toxiproxy:2.0.5=jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.testcontainers:testcontainers:2.0.5=jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.xerial.snappy:snappy-java:1.1.10.7=jmhRuntimeClasspath,runtimeClasspath,testRuntimeClasspath org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs empty= diff --git a/src/messaging/messaging-kafka/src/test/java/dev/caskeleton/messaging/kafka/KafkaAmbiguityChaosIT.java b/src/messaging/messaging-kafka/src/test/java/dev/caskeleton/messaging/kafka/KafkaAmbiguityChaosIT.java index bd55d42d..21b4561b 100644 --- a/src/messaging/messaging-kafka/src/test/java/dev/caskeleton/messaging/kafka/KafkaAmbiguityChaosIT.java +++ b/src/messaging/messaging-kafka/src/test/java/dev/caskeleton/messaging/kafka/KafkaAmbiguityChaosIT.java @@ -65,12 +65,49 @@ class KafkaAmbiguityChaosIT { @Test void aLostConfirmationIsReportedAsAmbiguousRatherThanGuessed() { try (Producer producer = new KafkaProducer<>(producerConfig())) { - KafkaMessagingTransport transport = new KafkaMessagingTransport("kafka-primary", 1, producer); + try (KafkaMessagingTransport transport = + new KafkaMessagingTransport("kafka-primary", 1, producer)) { - pauseBroker(); - PublishResult result; - try { - result = + pauseBroker(); + PublishResult result; + try { + result = + transport + .publish( + new TransportPublishRequest( + KafkaContractHarness.profile(), envelope(), PublishOptions.defaults())) + .toCompletableFuture() + .join() + .result(); + } finally { + resumeBroker(); + } + + assertThat(result.completion()).isEqualTo(PublishCompletion.AMBIGUOUS); + assertThat(result.evidence().transmission()) + .isEqualTo(TransmissionEvidence.MAY_HAVE_BEEN_TRANSMITTED); + assertThat(result.evidence().brokerAccepted()) + .as("nothing proves the broker took it") + .isFalse(); + assertThat(result.evidence().confirmationLevel()).isEqualTo(ConfirmationLevel.NONE); + assertThat(result.mayHaveBeenStored()).isTrue(); + + assertThat(result.failure().orElseThrow().category()).isEqualTo(FailureCategory.AMBIGUOUS); + assertThat(result.failure().orElseThrow().retryable()) + .as( + "an ambiguous publish is not silently retried; the caller decides, under the same id") + .isFalse(); + } + } + } + + @Test + void theBrokerRecoversAndPublishingConfirmsAgain() { + try (Producer producer = new KafkaProducer<>(producerConfig())) { + try (KafkaMessagingTransport transport = + new KafkaMessagingTransport("kafka-primary", 1, producer)) { + + PublishResult result = transport .publish( new TransportPublishRequest( @@ -78,41 +115,9 @@ class KafkaAmbiguityChaosIT { .toCompletableFuture() .join() .result(); - } finally { - resumeBroker(); + + assertThat(result.completion()).isEqualTo(PublishCompletion.CONFIRMED); } - - assertThat(result.completion()).isEqualTo(PublishCompletion.AMBIGUOUS); - assertThat(result.evidence().transmission()) - .isEqualTo(TransmissionEvidence.MAY_HAVE_BEEN_TRANSMITTED); - assertThat(result.evidence().brokerAccepted()) - .as("nothing proves the broker took it") - .isFalse(); - assertThat(result.evidence().confirmationLevel()).isEqualTo(ConfirmationLevel.NONE); - assertThat(result.mayHaveBeenStored()).isTrue(); - - assertThat(result.failure().orElseThrow().category()).isEqualTo(FailureCategory.AMBIGUOUS); - assertThat(result.failure().orElseThrow().retryable()) - .as("an ambiguous publish is not silently retried; the caller decides, under the same id") - .isFalse(); - } - } - - @Test - void theBrokerRecoversAndPublishingConfirmsAgain() { - try (Producer producer = new KafkaProducer<>(producerConfig())) { - KafkaMessagingTransport transport = new KafkaMessagingTransport("kafka-primary", 1, producer); - - PublishResult result = - transport - .publish( - new TransportPublishRequest( - KafkaContractHarness.profile(), envelope(), PublishOptions.defaults())) - .toCompletableFuture() - .join() - .result(); - - assertThat(result.completion()).isEqualTo(PublishCompletion.CONFIRMED); } } diff --git a/src/messaging/messaging-kafka/src/test/java/dev/caskeleton/messaging/kafka/KafkaBrokerCertificationIT.java b/src/messaging/messaging-kafka/src/test/java/dev/caskeleton/messaging/kafka/KafkaBrokerCertificationIT.java index 97ef840e..7a1dff71 100644 --- a/src/messaging/messaging-kafka/src/test/java/dev/caskeleton/messaging/kafka/KafkaBrokerCertificationIT.java +++ b/src/messaging/messaging-kafka/src/test/java/dev/caskeleton/messaging/kafka/KafkaBrokerCertificationIT.java @@ -124,6 +124,9 @@ class KafkaBrokerCertificationIT { private static Proxy brokerProxy; private static String bootstrapServers; + // Scope-based leak analysis cannot see the owner: the containers are static fields this class + // stops in its @AfterAll. + @SuppressWarnings("resource") @BeforeAll static void startBrokerBehindAProxy() throws IOException { network = Network.newNetwork(); diff --git a/src/messaging/messaging-kafka/src/test/java/dev/caskeleton/messaging/kafka/KafkaBrokerIT.java b/src/messaging/messaging-kafka/src/test/java/dev/caskeleton/messaging/kafka/KafkaBrokerIT.java index 10b9dce9..89db8a62 100644 --- a/src/messaging/messaging-kafka/src/test/java/dev/caskeleton/messaging/kafka/KafkaBrokerIT.java +++ b/src/messaging/messaging-kafka/src/test/java/dev/caskeleton/messaging/kafka/KafkaBrokerIT.java @@ -112,44 +112,48 @@ class KafkaBrokerIT { @Test void aStableProfilePublishConfirmsWithReplicationEvidence() { - KafkaMessagingTransport transport = new KafkaMessagingTransport("kafka-primary", 1, producer); + try (KafkaMessagingTransport transport = + new KafkaMessagingTransport("kafka-primary", 1, producer)) { - PublishResult result = - transport - .publish( - new TransportPublishRequest( - KafkaContractHarness.profile(), envelope(), PublishOptions.defaults())) - .toCompletableFuture() - .join() - .result(); + PublishResult result = + transport + .publish( + new TransportPublishRequest( + KafkaContractHarness.profile(), envelope(), PublishOptions.defaults())) + .toCompletableFuture() + .join() + .result(); - assertThat(result.completion()).isEqualTo(PublishCompletion.CONFIRMED); - assertThat(result.evidence().transmission()).isEqualTo(TransmissionEvidence.TRANSMITTED); - assertThat(result.evidence().confirmationLevel()) - .isEqualTo(ConfirmationLevel.REPLICATION_OR_PERSISTENCE_ACK); - assertThat(result.position()).isPresent(); + assertThat(result.completion()).isEqualTo(PublishCompletion.CONFIRMED); + assertThat(result.evidence().transmission()).isEqualTo(TransmissionEvidence.TRANSMITTED); + assertThat(result.evidence().confirmationLevel()) + .isEqualTo(ConfirmationLevel.REPLICATION_OR_PERSISTENCE_ACK); + assertThat(result.position()).isPresent(); + } } @Test void anInvalidTopicIsRejectedRatherThanReportedAmbiguous() { - KafkaMessagingTransport transport = new KafkaMessagingTransport("kafka-primary", 1, producer); + try (KafkaMessagingTransport transport = + new KafkaMessagingTransport("kafka-primary", 1, producer)) { - PublishResult result = - transport - .publish( - new TransportPublishRequest( - KafkaFixtureProfiles.withTopic("not a legal topic name!"), - envelope(), - PublishOptions.defaults())) - .toCompletableFuture() - .join() - .result(); + PublishResult result = + transport + .publish( + new TransportPublishRequest( + KafkaFixtureProfiles.withTopic("not a legal topic name!"), + envelope(), + PublishOptions.defaults())) + .toCompletableFuture() + .join() + .result(); - assertThat(result.completion()) - .as("an invalid topic is definitive: the record was never stored") - .isEqualTo(PublishCompletion.REJECTED); - assertThat(result.evidence().transmission()).isEqualTo(TransmissionEvidence.NOT_TRANSMITTED); - assertThat(result.failure().orElseThrow().retryable()).isFalse(); + assertThat(result.completion()) + .as("an invalid topic is definitive: the record was never stored") + .isEqualTo(PublishCompletion.REJECTED); + assertThat(result.evidence().transmission()).isEqualTo(TransmissionEvidence.NOT_TRANSMITTED); + assertThat(result.failure().orElseThrow().retryable()).isFalse(); + } } /** @@ -165,70 +169,74 @@ class KafkaBrokerIT { */ @Test void autoCreationMakesAnUndeclaredTopicIndistinguishableFromADeclaredOne() { - KafkaMessagingTransport transport = new KafkaMessagingTransport("kafka-primary", 1, producer); + try (KafkaMessagingTransport transport = + new KafkaMessagingTransport("kafka-primary", 1, producer)) { - PublishResult result = - transport - .publish( - new TransportPublishRequest( - KafkaFixtureProfiles.withTopic("undeclared.topic.v1"), - envelope(), - PublishOptions.defaults())) - .toCompletableFuture() - .join() - .result(); + PublishResult result = + transport + .publish( + new TransportPublishRequest( + KafkaFixtureProfiles.withTopic("undeclared.topic.v1"), + envelope(), + PublishOptions.defaults())) + .toCompletableFuture() + .join() + .result(); - assertThat(result.completion()).isEqualTo(PublishCompletion.CONFIRMED); - assertThat(KafkaContractHarness.profile().topologyAutoCreate()) - .as("production profiles must never permit this") - .isFalse(); + assertThat(result.completion()).isEqualTo(PublishCompletion.CONFIRMED); + assertThat(KafkaContractHarness.profile().topologyAutoCreate()) + .as("production profiles must never permit this") + .isFalse(); + } } @Test void aPublishedMessageIsConsumedWithItsIdentityIntactAndCommitted() { - KafkaMessagingTransport transport = new KafkaMessagingTransport("kafka-primary", 1, producer); - MessageEnvelope published = envelope(); + try (KafkaMessagingTransport transport = + new KafkaMessagingTransport("kafka-primary", 1, producer)) { + MessageEnvelope published = envelope(); - transport - .publish( - new TransportPublishRequest( - KafkaContractHarness.profile(), published, PublishOptions.defaults())) - .toCompletableFuture() - .join(); + transport + .publish( + new TransportPublishRequest( + KafkaContractHarness.profile(), published, PublishOptions.defaults())) + .toCompletableFuture() + .join(); - List handled = new ArrayList<>(); - KafkaConsumerRegistrar registrar = - new KafkaConsumerRegistrar( - new TransportConsumerSpec( - KafkaContractHarness.profile(), - delivery -> { - handled.add(delivery); - delivery.settlement().acknowledge().toCompletableFuture().join(); - return CompletableFuture.completedFuture(null); - }), - consumer, - Runnable::run, - new GracefulShutdownCoordinator(Duration.ofSeconds(30)), - Duration.ofMillis(500)); - registrar.subscribe(TOPIC); + List handled = new ArrayList<>(); + KafkaConsumerRegistrar registrar = + new KafkaConsumerRegistrar( + new TransportConsumerSpec( + KafkaContractHarness.profile(), + delivery -> { + handled.add(delivery); + delivery.settlement().acknowledge().toCompletableFuture().join(); + return CompletableFuture.completedFuture(null); + }), + consumer, + Runnable::run, + new GracefulShutdownCoordinator(Duration.ofSeconds(30)), + Duration.ofMillis(500)); + registrar.subscribe(TOPIC); - for (int attempt = 0; attempt < 20 && handled.isEmpty(); attempt++) { + for (int attempt = 0; attempt < 20 && handled.isEmpty(); attempt++) { + registrar.pollOnce(NOW); + } registrar.pollOnce(NOW); + + assertThat(handled) + .singleElement() + .satisfies( + delivery -> { + assertThat(delivery.envelope().messageId()).isEqualTo(published.messageId()); + assertThat(delivery.envelope().messageType()).isEqualTo(published.messageType()); + assertThat(delivery.envelope().payload().bytes()) + .isEqualTo(published.payload().bytes()); + }); + assertThat(registrar.committedOffset(new TopicPartition(TOPIC, 0))).isPresent(); + + registrar.close(); } - registrar.pollOnce(NOW); - - assertThat(handled) - .singleElement() - .satisfies( - delivery -> { - assertThat(delivery.envelope().messageId()).isEqualTo(published.messageId()); - assertThat(delivery.envelope().messageType()).isEqualTo(published.messageType()); - assertThat(delivery.envelope().payload().bytes()) - .isEqualTo(published.payload().bytes()); - }); - assertThat(registrar.committedOffset(new TopicPartition(TOPIC, 0))).isPresent(); - - registrar.close(); } private Properties producerConfig() { diff --git a/src/messaging/messaging-kafka/src/test/java/dev/caskeleton/messaging/kafka/KafkaConsumerRegistrarTest.java b/src/messaging/messaging-kafka/src/test/java/dev/caskeleton/messaging/kafka/KafkaConsumerRegistrarTest.java index f8e30bde..981b9417 100644 --- a/src/messaging/messaging-kafka/src/test/java/dev/caskeleton/messaging/kafka/KafkaConsumerRegistrarTest.java +++ b/src/messaging/messaging-kafka/src/test/java/dev/caskeleton/messaging/kafka/KafkaConsumerRegistrarTest.java @@ -193,7 +193,7 @@ class KafkaConsumerRegistrarTest { @DisplayName("an undecodable record commits only once quarantine confirms the write") void anUndecodableRecordCommitsAfterQuarantineConfirms() { java.util.List quarantined = new java.util.ArrayList<>(); - KafkaConsumerRegistrar registrar = + try (KafkaConsumerRegistrar registrar = new KafkaConsumerRegistrar( new TransportConsumerSpec(KafkaContractHarness.profile(), this::settleImmediately), consumer, @@ -203,16 +203,17 @@ class KafkaConsumerRegistrarTest { (record, reason) -> { quarantined.add(record.offset()); return true; - }); - assign(); - addRecordWithoutIdentityHeaders(10); + })) { + assign(); + addRecordWithoutIdentityHeaders(10); - registrar.pollOnce(NOW); - handlers.runAll(); - registrar.pollOnce(NOW); + registrar.pollOnce(NOW); + handlers.runAll(); + registrar.pollOnce(NOW); - assertThat(quarantined).containsExactly(10L); - assertThat(registrar.committedOffset(PARTITION)).hasValue(11L); + assertThat(quarantined).containsExactly(10L); + assertThat(registrar.committedOffset(PARTITION)).hasValue(11L); + } } @Test @@ -241,7 +242,7 @@ class KafkaConsumerRegistrarTest { @Test @DisplayName("a rejected executor submission leaves nothing in flight and re-reads the record") void aRejectedSubmissionIsUndone() { - KafkaConsumerRegistrar registrar = + try (KafkaConsumerRegistrar registrar = new KafkaConsumerRegistrar( new TransportConsumerSpec(KafkaContractHarness.profile(), this::settleImmediately), consumer, @@ -249,19 +250,20 @@ class KafkaConsumerRegistrarTest { throw new java.util.concurrent.RejectedExecutionException("pool is shutting down"); }, new GracefulShutdownCoordinator(Duration.ofSeconds(30)), - Duration.ofMillis(1)); - assign(); - addRecord(10); + Duration.ofMillis(1))) { + assign(); + addRecord(10); - int dispatched = registrar.pollOnce(NOW); + int dispatched = registrar.pollOnce(NOW); - assertThat(dispatched).isZero(); - assertThat(registrar.committedOffset(PARTITION)) - .as("an offset registered as delivered with no worker behind it never completes") - .isEmpty(); - assertThat(consumer.position(PARTITION)) - .as("the record must be re-read next cycle") - .isEqualTo(10L); + assertThat(dispatched).isZero(); + assertThat(registrar.committedOffset(PARTITION)) + .as("an offset registered as delivered with no worker behind it never completes") + .isEmpty(); + assertThat(consumer.position(PARTITION)) + .as("the record must be re-read next cycle") + .isEqualTo(10L); + } } @Test diff --git a/src/messaging/messaging-kafka/src/test/java/dev/caskeleton/messaging/kafka/KafkaConsumerSettlementIT.java b/src/messaging/messaging-kafka/src/test/java/dev/caskeleton/messaging/kafka/KafkaConsumerSettlementIT.java index 69a4b38c..ec0b8eb8 100644 --- a/src/messaging/messaging-kafka/src/test/java/dev/caskeleton/messaging/kafka/KafkaConsumerSettlementIT.java +++ b/src/messaging/messaging-kafka/src/test/java/dev/caskeleton/messaging/kafka/KafkaConsumerSettlementIT.java @@ -155,19 +155,21 @@ class KafkaConsumerSettlementIT { } private void publish(int count) { - KafkaMessagingTransport transport = new KafkaMessagingTransport("kafka-primary", 1, producer); - for (int index = 0; index < count; index++) { - transport - .publish( - new TransportPublishRequest( - KafkaFixtureProfiles.withTopic(topic), - KafkaTransactionFixtures.delivery(topic, "settle-" + index) - .outputs() - .get(0) - .envelope(), - PublishOptions.defaults())) - .toCompletableFuture() - .join(); + try (KafkaMessagingTransport transport = + new KafkaMessagingTransport("kafka-primary", 1, producer)) { + for (int index = 0; index < count; index++) { + transport + .publish( + new TransportPublishRequest( + KafkaFixtureProfiles.withTopic(topic), + KafkaTransactionFixtures.delivery(topic, "settle-" + index) + .outputs() + .get(0) + .envelope(), + PublishOptions.defaults())) + .toCompletableFuture() + .join(); + } } } diff --git a/src/messaging/messaging-nats-experimental/gradle.lockfile b/src/messaging/messaging-nats-experimental/gradle.lockfile index 328f63ab..03040a9c 100644 --- a/src/messaging/messaging-nats-experimental/gradle.lockfile +++ b/src/messaging/messaging-nats-experimental/gradle.lockfile @@ -1,7 +1,7 @@ # This is a Gradle generated file for dependency locking. # Manual edits can break the build and are not advised. # This file is expected to be part of source control. -com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor +com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,testAnnotationProcessor com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs com.github.spotbugs:spotbugs:4.10.2=spotbugs @@ -32,12 +32,12 @@ commons-io:commons-io:2.21.0=spotbugs info.picocli:picocli:4.7.7=checkstyle io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor -io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.micrometer:micrometer-core:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-commons:1.16.7=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-core:1.16.7=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-observation:1.16.7=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.nats:jnats:2.26.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor -jaxen:jaxen:2.0.0=spotbugs +jaxen:jaxen:2.0.6=spotbugs net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs org.antlr:antlr4-runtime:4.13.2=checkstyle @@ -47,15 +47,15 @@ org.apache.commons:commons-text:1.15.0=spotbugs org.apache.commons:commons-text:1.3=checkstyle org.apache.httpcomponents:httpclient:4.5.13=checkstyle org.apache.httpcomponents:httpcore:4.4.16=checkstyle -org.apache.logging.log4j:log4j-api:2.25.2=spotbugs -org.apache.logging.log4j:log4j-core:2.25.2=spotbugs +org.apache.logging.log4j:log4j-api:2.25.5=spotbugs +org.apache.logging.log4j:log4j-core:2.25.5=spotbugs org.apache.maven.doxia:doxia-core:1.12.0=checkstyle org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle org.apache.xbean:xbean-reflect:3.7=checkstyle org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath -org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath +org.assertj:assertj-core:3.27.7=testCompileClasspath,testRuntimeClasspath org.bouncycastle:bcprov-lts8on:2.73.10=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle @@ -64,15 +64,15 @@ org.codehaus.plexus:plexus-utils:3.3.0=checkstyle org.dom4j:dom4j:2.2.0=spotbugs org.hdrhistogram:HdrHistogram:2.2.2=runtimeClasspath,testRuntimeClasspath org.javassist:javassist:3.28.0-GA=checkstyle -org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath -org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-engine:6.0.1=testRuntimeClasspath -org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath -org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath +org.jspecify:jspecify:1.0.1=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.3=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.3=testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.3=testRuntimeClasspath +org.junit:junit-bom:6.0.3=testCompileClasspath,testRuntimeClasspath org.junit:junit-bom:6.1.0=spotbugs org.latencyutils:LatencyUtils:2.0.3=runtimeClasspath,testRuntimeClasspath org.mockito:mockito-core:5.20.0=mockitoAgent @@ -84,7 +84,8 @@ org.ow2.asm:asm-util:9.10.1=spotbugs org.ow2.asm:asm:9.10.1=spotbugs org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor org.reflections:reflections:0.10.2=checkstyle -org.slf4j:slf4j-api:2.0.17=spotbugs,spotbugsSlf4j -org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j +org.slf4j:slf4j-api:2.0.18=spotbugs,spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.18=checkstyle org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs empty= diff --git a/src/messaging/messaging-nats-experimental/src/test/java/dev/caskeleton/messaging/nats/NatsAdapterContractTest.java b/src/messaging/messaging-nats-experimental/src/test/java/dev/caskeleton/messaging/nats/NatsAdapterContractTest.java index f2f909d2..6e9dd33e 100644 --- a/src/messaging/messaging-nats-experimental/src/test/java/dev/caskeleton/messaging/nats/NatsAdapterContractTest.java +++ b/src/messaging/messaging-nats-experimental/src/test/java/dev/caskeleton/messaging/nats/NatsAdapterContractTest.java @@ -146,21 +146,23 @@ class NatsAdapterContractTest { @Test void aPublishThatNeverCompletesIsBoundedByTheCallersTimeout() { - NatsJetStreamTransport transport = + try (NatsJetStreamTransport transport = new NatsJetStreamTransport( "nats-primary", 1L, profile(Optional.empty()), - (subject, deduplicationId, request) -> new java.util.concurrent.CompletableFuture<>()); + (subject, deduplicationId, request) -> + new java.util.concurrent.CompletableFuture<>())) { - PublishResult result = await(transport.publish(requestWithTimeout(Duration.ofMillis(50)))); + PublishResult result = await(transport.publish(requestWithTimeout(Duration.ofMillis(50)))); - assertThat(result.completion()) - .as("the caller's deadline used to be ignored entirely") - .isEqualTo(PublishCompletion.AMBIGUOUS); - assertThat(result.failure()) - .hasValueSatisfying( - failure -> assertThat(failure.code()).isEqualTo("NATS_PUBLISH_TIMEOUT")); + assertThat(result.completion()) + .as("the caller's deadline used to be ignored entirely") + .isEqualTo(PublishCompletion.AMBIGUOUS); + assertThat(result.failure()) + .hasValueSatisfying( + failure -> assertThat(failure.code()).isEqualTo("NATS_PUBLISH_TIMEOUT")); + } } @Test diff --git a/src/messaging/messaging-observability/gradle.lockfile b/src/messaging/messaging-observability/gradle.lockfile index 8a9cedb6..f5ad1bb5 100644 --- a/src/messaging/messaging-observability/gradle.lockfile +++ b/src/messaging/messaging-observability/gradle.lockfile @@ -1,7 +1,7 @@ # This is a Gradle generated file for dependency locking. # Manual edits can break the build and are not advised. # This file is expected to be part of source control. -com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor +com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,testAnnotationProcessor com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs com.github.spotbugs:spotbugs:4.10.2=spotbugs @@ -32,11 +32,11 @@ commons-io:commons-io:2.21.0=spotbugs info.picocli:picocli:4.7.7=checkstyle io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor -io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.micrometer:micrometer-core:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-commons:1.16.7=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-core:1.16.7=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-observation:1.16.7=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor -jaxen:jaxen:2.0.0=spotbugs +jaxen:jaxen:2.0.6=spotbugs net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs org.antlr:antlr4-runtime:4.13.2=checkstyle @@ -46,15 +46,15 @@ org.apache.commons:commons-text:1.15.0=spotbugs org.apache.commons:commons-text:1.3=checkstyle org.apache.httpcomponents:httpclient:4.5.13=checkstyle org.apache.httpcomponents:httpcore:4.4.16=checkstyle -org.apache.logging.log4j:log4j-api:2.25.2=spotbugs -org.apache.logging.log4j:log4j-core:2.25.2=spotbugs +org.apache.logging.log4j:log4j-api:2.25.5=spotbugs +org.apache.logging.log4j:log4j-core:2.25.5=spotbugs org.apache.maven.doxia:doxia-core:1.12.0=checkstyle org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle org.apache.xbean:xbean-reflect:3.7=checkstyle org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath -org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath +org.assertj:assertj-core:3.27.7=testCompileClasspath,testRuntimeClasspath org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle @@ -62,15 +62,15 @@ org.codehaus.plexus:plexus-utils:3.3.0=checkstyle org.dom4j:dom4j:2.2.0=spotbugs org.hdrhistogram:HdrHistogram:2.2.2=runtimeClasspath,testRuntimeClasspath org.javassist:javassist:3.28.0-GA=checkstyle -org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath -org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-engine:6.0.1=testRuntimeClasspath -org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath -org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath +org.jspecify:jspecify:1.0.1=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.3=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.3=testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.3=testRuntimeClasspath +org.junit:junit-bom:6.0.3=testCompileClasspath,testRuntimeClasspath org.junit:junit-bom:6.1.0=spotbugs org.latencyutils:LatencyUtils:2.0.3=runtimeClasspath,testRuntimeClasspath org.mockito:mockito-core:5.20.0=mockitoAgent @@ -82,7 +82,8 @@ org.ow2.asm:asm-util:9.10.1=spotbugs org.ow2.asm:asm:9.10.1=spotbugs org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor org.reflections:reflections:0.10.2=checkstyle -org.slf4j:slf4j-api:2.0.17=spotbugs,spotbugsSlf4j -org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j +org.slf4j:slf4j-api:2.0.18=spotbugs,spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.18=checkstyle org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs empty= diff --git a/src/messaging/messaging-outbox-jdbc-postgresql/gradle.lockfile b/src/messaging/messaging-outbox-jdbc-postgresql/gradle.lockfile index 438bb2cc..93005bc0 100644 --- a/src/messaging/messaging-outbox-jdbc-postgresql/gradle.lockfile +++ b/src/messaging/messaging-outbox-jdbc-postgresql/gradle.lockfile @@ -1,11 +1,11 @@ # This is a Gradle generated file for dependency locking. # Manual edits can break the build and are not advised. # This file is expected to be part of source control. -com.fasterxml.jackson.core:jackson-annotations:2.20=testCompileClasspath,testRuntimeClasspath -com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor -com.github.docker-java:docker-java-api:3.7.0=testCompileClasspath,testRuntimeClasspath -com.github.docker-java:docker-java-transport-zerodep:3.7.0=testCompileClasspath,testRuntimeClasspath -com.github.docker-java:docker-java-transport:3.7.0=testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-annotations:2.21=testCompileClasspath,testRuntimeClasspath +com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,testAnnotationProcessor +com.github.docker-java:docker-java-api:3.7.1=testCompileClasspath,testRuntimeClasspath +com.github.docker-java:docker-java-transport-zerodep:3.7.1=testCompileClasspath,testRuntimeClasspath +com.github.docker-java:docker-java-transport:3.7.1=testCompileClasspath,testRuntimeClasspath com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs com.github.spotbugs:spotbugs:4.10.2=spotbugs @@ -35,15 +35,15 @@ commons-codec:commons-codec:1.19.0=testCompileClasspath,testRuntimeClasspath commons-collections:commons-collections:3.2.2=checkstyle commons-io:commons-io:2.20.0=testCompileClasspath,testRuntimeClasspath commons-io:commons-io:2.21.0=spotbugs -commons-logging:commons-logging:1.3.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +commons-logging:commons-logging:1.3.6=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath info.picocli:picocli:4.7.7=checkstyle io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor -io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.micrometer:micrometer-core:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-commons:1.16.7=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-core:1.16.7=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-observation:1.16.7=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor -jaxen:jaxen:2.0.0=spotbugs +jaxen:jaxen:2.0.6=spotbugs net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath net.java.dev.jna:jna:5.18.1=testCompileClasspath,testRuntimeClasspath net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs @@ -55,16 +55,16 @@ org.apache.commons:commons-text:1.15.0=spotbugs org.apache.commons:commons-text:1.3=checkstyle org.apache.httpcomponents:httpclient:4.5.13=checkstyle org.apache.httpcomponents:httpcore:4.4.16=checkstyle -org.apache.logging.log4j:log4j-api:2.25.2=spotbugs -org.apache.logging.log4j:log4j-core:2.25.2=spotbugs +org.apache.logging.log4j:log4j-api:2.25.5=spotbugs +org.apache.logging.log4j:log4j-core:2.25.5=spotbugs org.apache.maven.doxia:doxia-core:1.12.0=checkstyle org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle org.apache.xbean:xbean-reflect:3.7=checkstyle org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath -org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath -org.checkerframework:checker-qual:3.49.5=testRuntimeClasspath +org.assertj:assertj-core:3.27.7=testCompileClasspath,testRuntimeClasspath +org.checkerframework:checker-qual:3.55.1=testRuntimeClasspath org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle @@ -73,15 +73,15 @@ org.dom4j:dom4j:2.2.0=spotbugs org.hdrhistogram:HdrHistogram:2.2.2=runtimeClasspath,testRuntimeClasspath org.javassist:javassist:3.28.0-GA=checkstyle org.jetbrains:annotations:17.0.0=testCompileClasspath,testRuntimeClasspath -org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath -org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-engine:6.0.1=testRuntimeClasspath -org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath -org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath +org.jspecify:jspecify:1.0.1=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.3=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.3=testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.3=testRuntimeClasspath +org.junit:junit-bom:6.0.3=testCompileClasspath,testRuntimeClasspath org.junit:junit-bom:6.1.0=spotbugs org.latencyutils:LatencyUtils:2.0.3=runtimeClasspath,testRuntimeClasspath org.mockito:mockito-core:5.20.0=mockitoAgent @@ -92,19 +92,20 @@ org.ow2.asm:asm-tree:9.10.1=spotbugs org.ow2.asm:asm-util:9.10.1=spotbugs org.ow2.asm:asm:9.10.1=spotbugs org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor -org.postgresql:postgresql:42.7.8=testCompileClasspath,testRuntimeClasspath +org.postgresql:postgresql:42.7.13=testCompileClasspath,testRuntimeClasspath org.reflections:reflections:0.10.2=checkstyle org.rnorth.duct-tape:duct-tape:1.0.8=testCompileClasspath,testRuntimeClasspath -org.slf4j:slf4j-api:2.0.17=spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath -org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j -org.springframework:spring-beans:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-core:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-jdbc:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-tx:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.testcontainers:testcontainers-database-commons:2.0.2=testCompileClasspath,testRuntimeClasspath -org.testcontainers:testcontainers-jdbc:2.0.2=testCompileClasspath,testRuntimeClasspath -org.testcontainers:testcontainers-junit-jupiter:2.0.2=testCompileClasspath,testRuntimeClasspath -org.testcontainers:testcontainers-postgresql:2.0.2=testCompileClasspath,testRuntimeClasspath -org.testcontainers:testcontainers:2.0.2=testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-api:2.0.18=spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.18=checkstyle +org.springframework:spring-beans:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-core:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-jdbc:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-tx:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.testcontainers:testcontainers-database-commons:2.0.5=testCompileClasspath,testRuntimeClasspath +org.testcontainers:testcontainers-jdbc:2.0.5=testCompileClasspath,testRuntimeClasspath +org.testcontainers:testcontainers-junit-jupiter:2.0.5=testCompileClasspath,testRuntimeClasspath +org.testcontainers:testcontainers-postgresql:2.0.5=testCompileClasspath,testRuntimeClasspath +org.testcontainers:testcontainers:2.0.5=testCompileClasspath,testRuntimeClasspath org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs empty= diff --git a/src/messaging/messaging-outbox-jdbc-postgresql/src/test/java/dev/caskeleton/messaging/outbox/AdminOperationJournalPostgresIT.java b/src/messaging/messaging-outbox-jdbc-postgresql/src/test/java/dev/caskeleton/messaging/outbox/AdminOperationJournalPostgresIT.java index 42285161..5d435b52 100644 --- a/src/messaging/messaging-outbox-jdbc-postgresql/src/test/java/dev/caskeleton/messaging/outbox/AdminOperationJournalPostgresIT.java +++ b/src/messaging/messaging-outbox-jdbc-postgresql/src/test/java/dev/caskeleton/messaging/outbox/AdminOperationJournalPostgresIT.java @@ -45,6 +45,9 @@ class AdminOperationJournalPostgresIT { private static final String TICKET = "CHG-1042"; private static final PlanDigest DIGEST = PlanDigest.ofCanonical("REDRIVE|orders.v1.dlq|50"); + // Scope-based leak analysis cannot see the owner: @Container: the Testcontainers JUnit extension + // owns this lifecycle. + @SuppressWarnings("resource") @Container private static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer("postgres:16-alpine") diff --git a/src/messaging/messaging-outbox-jdbc-postgresql/src/test/java/dev/caskeleton/messaging/outbox/OutboxPostgresIT.java b/src/messaging/messaging-outbox-jdbc-postgresql/src/test/java/dev/caskeleton/messaging/outbox/OutboxPostgresIT.java index b8d9a5b7..eeb58841 100644 --- a/src/messaging/messaging-outbox-jdbc-postgresql/src/test/java/dev/caskeleton/messaging/outbox/OutboxPostgresIT.java +++ b/src/messaging/messaging-outbox-jdbc-postgresql/src/test/java/dev/caskeleton/messaging/outbox/OutboxPostgresIT.java @@ -45,6 +45,9 @@ class OutboxPostgresIT { private static final Instant NOW = Instant.parse("2026-08-10T09:15:00Z"); + // Scope-based leak analysis cannot see the owner: @Container: the Testcontainers JUnit extension + // owns this lifecycle. + @SuppressWarnings("resource") @Container private static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer("postgres:16-alpine") diff --git a/src/messaging/messaging-policy/gradle.lockfile b/src/messaging/messaging-policy/gradle.lockfile index 599ff921..e2c95854 100644 --- a/src/messaging/messaging-policy/gradle.lockfile +++ b/src/messaging/messaging-policy/gradle.lockfile @@ -1,7 +1,7 @@ # This is a Gradle generated file for dependency locking. # Manual edits can break the build and are not advised. # This file is expected to be part of source control. -com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor +com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,testAnnotationProcessor com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs com.github.spotbugs:spotbugs:4.10.2=spotbugs @@ -33,7 +33,7 @@ info.picocli:picocli:4.7.7=checkstyle io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor -jaxen:jaxen:2.0.0=spotbugs +jaxen:jaxen:2.0.6=spotbugs net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs org.antlr:antlr4-runtime:4.13.2=checkstyle @@ -43,30 +43,30 @@ org.apache.commons:commons-text:1.15.0=spotbugs org.apache.commons:commons-text:1.3=checkstyle org.apache.httpcomponents:httpclient:4.5.13=checkstyle org.apache.httpcomponents:httpcore:4.4.16=checkstyle -org.apache.logging.log4j:log4j-api:2.25.2=spotbugs -org.apache.logging.log4j:log4j-core:2.25.2=spotbugs +org.apache.logging.log4j:log4j-api:2.25.5=spotbugs +org.apache.logging.log4j:log4j-core:2.25.5=spotbugs org.apache.maven.doxia:doxia-core:1.12.0=checkstyle org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle org.apache.xbean:xbean-reflect:3.7=checkstyle org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath -org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath +org.assertj:assertj-core:3.27.7=testCompileClasspath,testRuntimeClasspath org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle org.codehaus.plexus:plexus-utils:3.3.0=checkstyle org.dom4j:dom4j:2.2.0=spotbugs org.javassist:javassist:3.28.0-GA=checkstyle -org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,testAnnotationProcessor,testCompileClasspath -org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath -org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-engine:6.0.1=testRuntimeClasspath -org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath -org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath +org.jspecify:jspecify:1.0.1=annotationProcessor,checkstyle,testAnnotationProcessor,testCompileClasspath +org.junit.jupiter:junit-jupiter-api:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.3=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.3=testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.3=testRuntimeClasspath +org.junit:junit-bom:6.0.3=testCompileClasspath,testRuntimeClasspath org.junit:junit-bom:6.1.0=spotbugs org.mockito:mockito-core:5.20.0=mockitoAgent org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath @@ -77,7 +77,8 @@ org.ow2.asm:asm-util:9.10.1=spotbugs org.ow2.asm:asm:9.10.1=spotbugs org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor org.reflections:reflections:0.10.2=checkstyle -org.slf4j:slf4j-api:2.0.17=spotbugs,spotbugsSlf4j -org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j +org.slf4j:slf4j-api:2.0.18=spotbugs,spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.18=checkstyle org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs empty=compileClasspath,runtimeClasspath diff --git a/src/messaging/messaging-pulsar-experimental/gradle.lockfile b/src/messaging/messaging-pulsar-experimental/gradle.lockfile index 460caa89..86f863d6 100644 --- a/src/messaging/messaging-pulsar-experimental/gradle.lockfile +++ b/src/messaging/messaging-pulsar-experimental/gradle.lockfile @@ -1,8 +1,8 @@ # This is a Gradle generated file for dependency locking. # Manual edits can break the build and are not advised. # This file is expected to be part of source control. -com.fasterxml.jackson.core:jackson-annotations:2.20=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor +com.fasterxml.jackson.core:jackson-annotations:2.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,testAnnotationProcessor com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs com.github.spotbugs:spotbugs:4.10.2=spotbugs @@ -33,16 +33,16 @@ commons-io:commons-io:2.21.0=spotbugs info.picocli:picocli:4.7.7=checkstyle io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor -io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.micrometer:micrometer-core:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-commons:1.16.7=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-core:1.16.7=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-observation:1.16.7=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.opentelemetry:opentelemetry-api-incubator:1.45.0-alpha=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.opentelemetry:opentelemetry-api:1.55.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.opentelemetry:opentelemetry-common:1.55.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.opentelemetry:opentelemetry-context:1.55.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor javax.validation:validation-api:1.1.0.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -jaxen:jaxen:2.0.0=spotbugs +jaxen:jaxen:2.0.6=spotbugs net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs org.antlr:antlr4-runtime:4.13.2=checkstyle @@ -52,19 +52,19 @@ org.apache.commons:commons-text:1.15.0=spotbugs org.apache.commons:commons-text:1.3=checkstyle org.apache.httpcomponents:httpclient:4.5.13=checkstyle org.apache.httpcomponents:httpcore:4.4.16=checkstyle -org.apache.logging.log4j:log4j-api:2.25.2=spotbugs -org.apache.logging.log4j:log4j-core:2.25.2=spotbugs +org.apache.logging.log4j:log4j-api:2.25.5=spotbugs +org.apache.logging.log4j:log4j-core:2.25.5=spotbugs org.apache.maven.doxia:doxia-core:1.12.0=checkstyle org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle -org.apache.pulsar:bouncy-castle-bc:4.1.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.apache.pulsar:pulsar-client-admin-api:4.1.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.apache.pulsar:pulsar-client-api:4.1.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.pulsar:bouncy-castle-bc:4.1.3=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.pulsar:pulsar-client-admin-api:4.1.3=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.pulsar:pulsar-client-api:4.1.3=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.apache.pulsar:pulsar-client:4.0.3=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.apache.xbean:xbean-reflect:3.7=checkstyle org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath -org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath +org.assertj:assertj-core:3.27.7=testCompileClasspath,testRuntimeClasspath org.bouncycastle:bcpkix-jdk18on:1.81=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.bouncycastle:bcprov-ext-jdk18on:1.78.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.bouncycastle:bcprov-jdk18on:1.81.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath @@ -76,15 +76,15 @@ org.codehaus.plexus:plexus-utils:3.3.0=checkstyle org.dom4j:dom4j:2.2.0=spotbugs org.hdrhistogram:HdrHistogram:2.2.2=runtimeClasspath,testRuntimeClasspath org.javassist:javassist:3.28.0-GA=checkstyle -org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath -org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-engine:6.0.1=testRuntimeClasspath -org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath -org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath +org.jspecify:jspecify:1.0.1=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.3=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.3=testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.3=testRuntimeClasspath +org.junit:junit-bom:6.0.3=testCompileClasspath,testRuntimeClasspath org.junit:junit-bom:6.1.0=spotbugs org.latencyutils:LatencyUtils:2.0.3=runtimeClasspath,testRuntimeClasspath org.mockito:mockito-core:5.20.0=mockitoAgent @@ -96,7 +96,8 @@ org.ow2.asm:asm-util:9.10.1=spotbugs org.ow2.asm:asm:9.10.1=spotbugs org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor org.reflections:reflections:0.10.2=checkstyle -org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath -org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j +org.slf4j:slf4j-api:2.0.18=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.18=checkstyle org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs empty= diff --git a/src/messaging/messaging-rabbit/gradle.lockfile b/src/messaging/messaging-rabbit/gradle.lockfile index 010d9a29..703374a1 100644 --- a/src/messaging/messaging-rabbit/gradle.lockfile +++ b/src/messaging/messaging-rabbit/gradle.lockfile @@ -1,11 +1,11 @@ # This is a Gradle generated file for dependency locking. # Manual edits can break the build and are not advised. # This file is expected to be part of source control. -com.fasterxml.jackson.core:jackson-annotations:2.20=jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor -com.github.docker-java:docker-java-api:3.7.0=jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -com.github.docker-java:docker-java-transport-zerodep:3.7.0=jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -com.github.docker-java:docker-java-transport:3.7.0=jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-annotations:2.21=jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor +com.github.docker-java:docker-java-api:3.7.1=jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.github.docker-java:docker-java-transport-zerodep:3.7.1=jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.github.docker-java:docker-java-transport:3.7.1=jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs com.github.spotbugs:spotbugs:4.10.2=spotbugs @@ -36,13 +36,13 @@ commons-codec:commons-codec:1.19.0=jmhCompileClasspath,jmhRuntimeClasspath,testC commons-collections:commons-collections:3.2.2=checkstyle commons-io:commons-io:2.20.0=jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath commons-io:commons-io:2.21.0=spotbugs -commons-logging:commons-logging:1.3.5=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +commons-logging:commons-logging:1.3.6=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath info.picocli:picocli:4.7.7=checkstyle io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor -io.micrometer:micrometer-commons:1.16.0=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.micrometer:micrometer-core:1.16.0=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.micrometer:micrometer-observation:1.16.0=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-commons:1.16.7=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-core:1.16.7=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-observation:1.16.7=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.netty:netty-buffer:4.2.17.Final=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.netty:netty-codec-base:4.2.17.Final=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.netty:netty-codec-compression:4.2.17.Final=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath @@ -55,7 +55,7 @@ io.netty:netty-resolver:4.2.17.Final=compileClasspath,jmhCompileClasspath,jmhRun io.netty:netty-transport-native-unix-common:4.2.17.Final=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.netty:netty-transport:4.2.17.Final=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath javax.inject:javax.inject:1=annotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor -jaxen:jaxen:2.0.0=spotbugs +jaxen:jaxen:2.0.6=spotbugs net.bytebuddy:byte-buddy:1.17.8=jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath net.java.dev.jna:jna:5.18.1=jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath net.sf.jopt-simple:jopt-simple:5.0.4=jmhAnnotationProcessor,jmhCompileClasspath,jmhRuntimeClasspath @@ -69,15 +69,15 @@ org.apache.commons:commons-text:1.15.0=spotbugs org.apache.commons:commons-text:1.3=checkstyle org.apache.httpcomponents:httpclient:4.5.13=checkstyle org.apache.httpcomponents:httpcore:4.4.16=checkstyle -org.apache.logging.log4j:log4j-api:2.25.2=spotbugs -org.apache.logging.log4j:log4j-core:2.25.2=spotbugs +org.apache.logging.log4j:log4j-api:2.25.5=spotbugs +org.apache.logging.log4j:log4j-core:2.25.5=spotbugs org.apache.maven.doxia:doxia-core:1.12.0=checkstyle org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle org.apache.xbean:xbean-reflect:3.7=checkstyle org.apiguardian:apiguardian-api:1.1.2=jmhCompileClasspath,testCompileClasspath -org.assertj:assertj-core:3.27.6=jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.assertj:assertj-core:3.27.7=jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle @@ -86,15 +86,15 @@ org.dom4j:dom4j:2.2.0=spotbugs org.hdrhistogram:HdrHistogram:2.2.2=jmhRuntimeClasspath,runtimeClasspath,testRuntimeClasspath org.javassist:javassist:3.28.0-GA=checkstyle org.jetbrains:annotations:17.0.0=jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,jmhAnnotationProcessor,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-api:6.0.1=jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-engine:6.0.1=jmhRuntimeClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-params:6.0.1=jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter:6.0.1=jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-commons:6.0.1=jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-engine:6.0.1=jmhRuntimeClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-launcher:6.0.1=jmhRuntimeClasspath,testRuntimeClasspath -org.junit:junit-bom:6.0.1=jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.jspecify:jspecify:1.0.1=annotationProcessor,checkstyle,compileClasspath,jmhAnnotationProcessor,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:6.0.3=jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.3=jmhRuntimeClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.3=jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.3=jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.3=jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.3=jmhRuntimeClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.3=jmhRuntimeClasspath,testRuntimeClasspath +org.junit:junit-bom:6.0.3=jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.junit:junit-bom:6.1.0=spotbugs org.latencyutils:LatencyUtils:2.0.3=jmhRuntimeClasspath,runtimeClasspath,testRuntimeClasspath org.mockito:mockito-core:5.20.0=mockitoAgent @@ -109,19 +109,20 @@ org.ow2.asm:asm:9.10.1=spotbugs org.pcollections:pcollections:4.0.1=annotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor org.reflections:reflections:0.10.2=checkstyle org.rnorth.duct-tape:duct-tape:1.0.8=jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.slf4j:slf4j-api:2.0.17=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath -org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j -org.springframework.amqp:spring-amqp:4.0.0=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.amqp:spring-rabbit:4.0.0=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-aop:7.0.1=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-beans:7.0.1=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-context:7.0.1=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-core:7.0.1=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-expression:7.0.1=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-messaging:7.0.1=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-tx:7.0.1=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.testcontainers:testcontainers-junit-jupiter:2.0.2=jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.testcontainers:testcontainers-rabbitmq:2.0.2=jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.testcontainers:testcontainers:2.0.2=jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-api:2.0.18=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.18=checkstyle +org.springframework.amqp:spring-amqp:4.0.5=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.amqp:spring-rabbit:4.0.5=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-aop:7.0.9=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-beans:7.0.9=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-context:7.0.9=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-core:7.0.9=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-expression:7.0.9=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-messaging:7.0.9=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-tx:7.0.9=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.testcontainers:testcontainers-junit-jupiter:2.0.5=jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.testcontainers:testcontainers-rabbitmq:2.0.5=jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.testcontainers:testcontainers:2.0.5=jmhCompileClasspath,jmhRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs empty= diff --git a/src/messaging/messaging-rabbit/src/test/java/dev/caskeleton/messaging/rabbit/RabbitRuntimeTest.java b/src/messaging/messaging-rabbit/src/test/java/dev/caskeleton/messaging/rabbit/RabbitRuntimeTest.java index 7a2f79c7..b45e35e6 100644 --- a/src/messaging/messaging-rabbit/src/test/java/dev/caskeleton/messaging/rabbit/RabbitRuntimeTest.java +++ b/src/messaging/messaging-rabbit/src/test/java/dev/caskeleton/messaging/rabbit/RabbitRuntimeTest.java @@ -178,18 +178,19 @@ class RabbitRuntimeTest { void drainingRefusesNewDeliveries() { RecordingOperations operations = new RecordingOperations(); GracefulShutdownCoordinator shutdown = new GracefulShutdownCoordinator(Duration.ofSeconds(30)); - RabbitConsumerRegistrar registrar = + try (RabbitConsumerRegistrar registrar = new RabbitConsumerRegistrar( new TransportConsumerSpec( RabbitProfileFixtures.workQueueDestination(), this::acknowledge), operations, - shutdown); + shutdown)) { - shutdown.beginDrain(NOW); - boolean accepted = registrar.onMessage(deliveredMessage(7L), NOW); + shutdown.beginDrain(NOW); + boolean accepted = registrar.onMessage(deliveredMessage(7L), NOW); - assertThat(accepted).isFalse(); - assertThat(operations.calls).isEmpty(); + assertThat(accepted).isFalse(); + assertThat(operations.calls).isEmpty(); + } } @Test diff --git a/src/messaging/messaging-reliability-api/gradle.lockfile b/src/messaging/messaging-reliability-api/gradle.lockfile index 599ff921..e2c95854 100644 --- a/src/messaging/messaging-reliability-api/gradle.lockfile +++ b/src/messaging/messaging-reliability-api/gradle.lockfile @@ -1,7 +1,7 @@ # This is a Gradle generated file for dependency locking. # Manual edits can break the build and are not advised. # This file is expected to be part of source control. -com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor +com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,testAnnotationProcessor com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs com.github.spotbugs:spotbugs:4.10.2=spotbugs @@ -33,7 +33,7 @@ info.picocli:picocli:4.7.7=checkstyle io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor -jaxen:jaxen:2.0.0=spotbugs +jaxen:jaxen:2.0.6=spotbugs net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs org.antlr:antlr4-runtime:4.13.2=checkstyle @@ -43,30 +43,30 @@ org.apache.commons:commons-text:1.15.0=spotbugs org.apache.commons:commons-text:1.3=checkstyle org.apache.httpcomponents:httpclient:4.5.13=checkstyle org.apache.httpcomponents:httpcore:4.4.16=checkstyle -org.apache.logging.log4j:log4j-api:2.25.2=spotbugs -org.apache.logging.log4j:log4j-core:2.25.2=spotbugs +org.apache.logging.log4j:log4j-api:2.25.5=spotbugs +org.apache.logging.log4j:log4j-core:2.25.5=spotbugs org.apache.maven.doxia:doxia-core:1.12.0=checkstyle org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle org.apache.xbean:xbean-reflect:3.7=checkstyle org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath -org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath +org.assertj:assertj-core:3.27.7=testCompileClasspath,testRuntimeClasspath org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle org.codehaus.plexus:plexus-utils:3.3.0=checkstyle org.dom4j:dom4j:2.2.0=spotbugs org.javassist:javassist:3.28.0-GA=checkstyle -org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,testAnnotationProcessor,testCompileClasspath -org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath -org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-engine:6.0.1=testRuntimeClasspath -org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath -org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath +org.jspecify:jspecify:1.0.1=annotationProcessor,checkstyle,testAnnotationProcessor,testCompileClasspath +org.junit.jupiter:junit-jupiter-api:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.3=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.3=testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.3=testRuntimeClasspath +org.junit:junit-bom:6.0.3=testCompileClasspath,testRuntimeClasspath org.junit:junit-bom:6.1.0=spotbugs org.mockito:mockito-core:5.20.0=mockitoAgent org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath @@ -77,7 +77,8 @@ org.ow2.asm:asm-util:9.10.1=spotbugs org.ow2.asm:asm:9.10.1=spotbugs org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor org.reflections:reflections:0.10.2=checkstyle -org.slf4j:slf4j-api:2.0.17=spotbugs,spotbugsSlf4j -org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j +org.slf4j:slf4j-api:2.0.18=spotbugs,spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.18=checkstyle org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs empty=compileClasspath,runtimeClasspath diff --git a/src/messaging/messaging-runtime-core/gradle.lockfile b/src/messaging/messaging-runtime-core/gradle.lockfile index 8a9cedb6..f5ad1bb5 100644 --- a/src/messaging/messaging-runtime-core/gradle.lockfile +++ b/src/messaging/messaging-runtime-core/gradle.lockfile @@ -1,7 +1,7 @@ # This is a Gradle generated file for dependency locking. # Manual edits can break the build and are not advised. # This file is expected to be part of source control. -com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor +com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,testAnnotationProcessor com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs com.github.spotbugs:spotbugs:4.10.2=spotbugs @@ -32,11 +32,11 @@ commons-io:commons-io:2.21.0=spotbugs info.picocli:picocli:4.7.7=checkstyle io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor -io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.micrometer:micrometer-core:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-commons:1.16.7=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-core:1.16.7=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-observation:1.16.7=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor -jaxen:jaxen:2.0.0=spotbugs +jaxen:jaxen:2.0.6=spotbugs net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs org.antlr:antlr4-runtime:4.13.2=checkstyle @@ -46,15 +46,15 @@ org.apache.commons:commons-text:1.15.0=spotbugs org.apache.commons:commons-text:1.3=checkstyle org.apache.httpcomponents:httpclient:4.5.13=checkstyle org.apache.httpcomponents:httpcore:4.4.16=checkstyle -org.apache.logging.log4j:log4j-api:2.25.2=spotbugs -org.apache.logging.log4j:log4j-core:2.25.2=spotbugs +org.apache.logging.log4j:log4j-api:2.25.5=spotbugs +org.apache.logging.log4j:log4j-core:2.25.5=spotbugs org.apache.maven.doxia:doxia-core:1.12.0=checkstyle org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle org.apache.xbean:xbean-reflect:3.7=checkstyle org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath -org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath +org.assertj:assertj-core:3.27.7=testCompileClasspath,testRuntimeClasspath org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle @@ -62,15 +62,15 @@ org.codehaus.plexus:plexus-utils:3.3.0=checkstyle org.dom4j:dom4j:2.2.0=spotbugs org.hdrhistogram:HdrHistogram:2.2.2=runtimeClasspath,testRuntimeClasspath org.javassist:javassist:3.28.0-GA=checkstyle -org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath -org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-engine:6.0.1=testRuntimeClasspath -org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath -org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath +org.jspecify:jspecify:1.0.1=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.3=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.3=testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.3=testRuntimeClasspath +org.junit:junit-bom:6.0.3=testCompileClasspath,testRuntimeClasspath org.junit:junit-bom:6.1.0=spotbugs org.latencyutils:LatencyUtils:2.0.3=runtimeClasspath,testRuntimeClasspath org.mockito:mockito-core:5.20.0=mockitoAgent @@ -82,7 +82,8 @@ org.ow2.asm:asm-util:9.10.1=spotbugs org.ow2.asm:asm:9.10.1=spotbugs org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor org.reflections:reflections:0.10.2=checkstyle -org.slf4j:slf4j-api:2.0.17=spotbugs,spotbugsSlf4j -org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j +org.slf4j:slf4j-api:2.0.18=spotbugs,spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.18=checkstyle org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs empty= diff --git a/src/messaging/messaging-schema-api/gradle.lockfile b/src/messaging/messaging-schema-api/gradle.lockfile index 599ff921..e2c95854 100644 --- a/src/messaging/messaging-schema-api/gradle.lockfile +++ b/src/messaging/messaging-schema-api/gradle.lockfile @@ -1,7 +1,7 @@ # This is a Gradle generated file for dependency locking. # Manual edits can break the build and are not advised. # This file is expected to be part of source control. -com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor +com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,testAnnotationProcessor com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs com.github.spotbugs:spotbugs:4.10.2=spotbugs @@ -33,7 +33,7 @@ info.picocli:picocli:4.7.7=checkstyle io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor -jaxen:jaxen:2.0.0=spotbugs +jaxen:jaxen:2.0.6=spotbugs net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs org.antlr:antlr4-runtime:4.13.2=checkstyle @@ -43,30 +43,30 @@ org.apache.commons:commons-text:1.15.0=spotbugs org.apache.commons:commons-text:1.3=checkstyle org.apache.httpcomponents:httpclient:4.5.13=checkstyle org.apache.httpcomponents:httpcore:4.4.16=checkstyle -org.apache.logging.log4j:log4j-api:2.25.2=spotbugs -org.apache.logging.log4j:log4j-core:2.25.2=spotbugs +org.apache.logging.log4j:log4j-api:2.25.5=spotbugs +org.apache.logging.log4j:log4j-core:2.25.5=spotbugs org.apache.maven.doxia:doxia-core:1.12.0=checkstyle org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle org.apache.xbean:xbean-reflect:3.7=checkstyle org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath -org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath +org.assertj:assertj-core:3.27.7=testCompileClasspath,testRuntimeClasspath org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle org.codehaus.plexus:plexus-utils:3.3.0=checkstyle org.dom4j:dom4j:2.2.0=spotbugs org.javassist:javassist:3.28.0-GA=checkstyle -org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,testAnnotationProcessor,testCompileClasspath -org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath -org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-engine:6.0.1=testRuntimeClasspath -org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath -org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath +org.jspecify:jspecify:1.0.1=annotationProcessor,checkstyle,testAnnotationProcessor,testCompileClasspath +org.junit.jupiter:junit-jupiter-api:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.3=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.3=testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.3=testRuntimeClasspath +org.junit:junit-bom:6.0.3=testCompileClasspath,testRuntimeClasspath org.junit:junit-bom:6.1.0=spotbugs org.mockito:mockito-core:5.20.0=mockitoAgent org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath @@ -77,7 +77,8 @@ org.ow2.asm:asm-util:9.10.1=spotbugs org.ow2.asm:asm:9.10.1=spotbugs org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor org.reflections:reflections:0.10.2=checkstyle -org.slf4j:slf4j-api:2.0.17=spotbugs,spotbugsSlf4j -org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j +org.slf4j:slf4j-api:2.0.18=spotbugs,spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.18=checkstyle org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs empty=compileClasspath,runtimeClasspath diff --git a/src/messaging/messaging-schema-avro/gradle.lockfile b/src/messaging/messaging-schema-avro/gradle.lockfile index 46c2ee7a..76fe183e 100644 --- a/src/messaging/messaging-schema-avro/gradle.lockfile +++ b/src/messaging/messaging-schema-avro/gradle.lockfile @@ -1,11 +1,11 @@ # This is a Gradle generated file for dependency locking. # Manual edits can break the build and are not advised. # This file is expected to be part of source control. -com.fasterxml.jackson.core:jackson-annotations:2.20=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.fasterxml.jackson.core:jackson-core:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.fasterxml.jackson.core:jackson-databind:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.fasterxml.jackson:jackson-bom:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor +com.fasterxml.jackson.core:jackson-annotations:2.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-core:2.21.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-databind:2.21.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson:jackson-bom:2.21.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,testAnnotationProcessor com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs com.github.spotbugs:spotbugs:4.10.2=spotbugs @@ -39,7 +39,7 @@ info.picocli:picocli:4.7.7=checkstyle io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor -jaxen:jaxen:2.0.0=spotbugs +jaxen:jaxen:2.0.6=spotbugs net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs org.antlr:antlr4-runtime:4.13.2=checkstyle @@ -51,30 +51,30 @@ org.apache.commons:commons-text:1.15.0=spotbugs org.apache.commons:commons-text:1.3=checkstyle org.apache.httpcomponents:httpclient:4.5.13=checkstyle org.apache.httpcomponents:httpcore:4.4.16=checkstyle -org.apache.logging.log4j:log4j-api:2.25.2=spotbugs -org.apache.logging.log4j:log4j-core:2.25.2=spotbugs +org.apache.logging.log4j:log4j-api:2.25.5=spotbugs +org.apache.logging.log4j:log4j-core:2.25.5=spotbugs org.apache.maven.doxia:doxia-core:1.12.0=checkstyle org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle org.apache.xbean:xbean-reflect:3.7=checkstyle org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath -org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath +org.assertj:assertj-core:3.27.7=testCompileClasspath,testRuntimeClasspath org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle org.codehaus.plexus:plexus-utils:3.3.0=checkstyle org.dom4j:dom4j:2.2.0=spotbugs org.javassist:javassist:3.28.0-GA=checkstyle -org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,testAnnotationProcessor,testCompileClasspath -org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath -org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-engine:6.0.1=testRuntimeClasspath -org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath -org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath +org.jspecify:jspecify:1.0.1=annotationProcessor,checkstyle,testAnnotationProcessor,testCompileClasspath +org.junit.jupiter:junit-jupiter-api:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.3=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.3=testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.3=testRuntimeClasspath +org.junit:junit-bom:6.0.3=testCompileClasspath,testRuntimeClasspath org.junit:junit-bom:6.1.0=spotbugs org.mockito:mockito-core:5.20.0=mockitoAgent org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath @@ -85,7 +85,8 @@ org.ow2.asm:asm-util:9.10.1=spotbugs org.ow2.asm:asm:9.10.1=spotbugs org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor org.reflections:reflections:0.10.2=checkstyle -org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath -org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j +org.slf4j:slf4j-api:2.0.18=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.18=checkstyle org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs empty= diff --git a/src/messaging/messaging-schema-json/gradle.lockfile b/src/messaging/messaging-schema-json/gradle.lockfile index f0c349e9..f8a18d32 100644 --- a/src/messaging/messaging-schema-json/gradle.lockfile +++ b/src/messaging/messaging-schema-json/gradle.lockfile @@ -1,8 +1,8 @@ # This is a Gradle generated file for dependency locking. # Manual edits can break the build and are not advised. # This file is expected to be part of source control. -com.fasterxml.jackson.core:jackson-annotations:2.20=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor +com.fasterxml.jackson.core:jackson-annotations:2.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,testAnnotationProcessor com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs com.github.spotbugs:spotbugs:4.10.2=spotbugs @@ -34,7 +34,7 @@ info.picocli:picocli:4.7.7=checkstyle io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor -jaxen:jaxen:2.0.0=spotbugs +jaxen:jaxen:2.0.6=spotbugs net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs org.antlr:antlr4-runtime:4.13.2=checkstyle @@ -44,30 +44,30 @@ org.apache.commons:commons-text:1.15.0=spotbugs org.apache.commons:commons-text:1.3=checkstyle org.apache.httpcomponents:httpclient:4.5.13=checkstyle org.apache.httpcomponents:httpcore:4.4.16=checkstyle -org.apache.logging.log4j:log4j-api:2.25.2=spotbugs -org.apache.logging.log4j:log4j-core:2.25.2=spotbugs +org.apache.logging.log4j:log4j-api:2.25.5=spotbugs +org.apache.logging.log4j:log4j-core:2.25.5=spotbugs org.apache.maven.doxia:doxia-core:1.12.0=checkstyle org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle org.apache.xbean:xbean-reflect:3.7=checkstyle org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath -org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath +org.assertj:assertj-core:3.27.7=testCompileClasspath,testRuntimeClasspath org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle org.codehaus.plexus:plexus-utils:3.3.0=checkstyle org.dom4j:dom4j:2.2.0=spotbugs org.javassist:javassist:3.28.0-GA=checkstyle -org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,testAnnotationProcessor,testCompileClasspath -org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath -org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-engine:6.0.1=testRuntimeClasspath -org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath -org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath +org.jspecify:jspecify:1.0.1=annotationProcessor,checkstyle,testAnnotationProcessor,testCompileClasspath +org.junit.jupiter:junit-jupiter-api:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.3=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.3=testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.3=testRuntimeClasspath +org.junit:junit-bom:6.0.3=testCompileClasspath,testRuntimeClasspath org.junit:junit-bom:6.1.0=spotbugs org.mockito:mockito-core:5.20.0=mockitoAgent org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath @@ -78,10 +78,11 @@ org.ow2.asm:asm-util:9.10.1=spotbugs org.ow2.asm:asm:9.10.1=spotbugs org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor org.reflections:reflections:0.10.2=checkstyle -org.slf4j:slf4j-api:2.0.17=spotbugs,spotbugsSlf4j -org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j +org.slf4j:slf4j-api:2.0.18=spotbugs,spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.18=checkstyle org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs -tools.jackson.core:jackson-core:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -tools.jackson.core:jackson-databind:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -tools.jackson:jackson-bom:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +tools.jackson.core:jackson-core:3.1.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +tools.jackson.core:jackson-databind:3.1.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +tools.jackson:jackson-bom:3.1.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath empty= diff --git a/src/messaging/messaging-schema-protobuf/gradle.lockfile b/src/messaging/messaging-schema-protobuf/gradle.lockfile index a926f11a..15b6267e 100644 --- a/src/messaging/messaging-schema-protobuf/gradle.lockfile +++ b/src/messaging/messaging-schema-protobuf/gradle.lockfile @@ -1,7 +1,7 @@ # This is a Gradle generated file for dependency locking. # Manual edits can break the build and are not advised. # This file is expected to be part of source control. -com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor +com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,testAnnotationProcessor com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs com.github.spotbugs:spotbugs:4.10.2=spotbugs @@ -34,7 +34,7 @@ info.picocli:picocli:4.7.7=checkstyle io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor -jaxen:jaxen:2.0.0=spotbugs +jaxen:jaxen:2.0.6=spotbugs net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs org.antlr:antlr4-runtime:4.13.2=checkstyle @@ -44,30 +44,30 @@ org.apache.commons:commons-text:1.15.0=spotbugs org.apache.commons:commons-text:1.3=checkstyle org.apache.httpcomponents:httpclient:4.5.13=checkstyle org.apache.httpcomponents:httpcore:4.4.16=checkstyle -org.apache.logging.log4j:log4j-api:2.25.2=spotbugs -org.apache.logging.log4j:log4j-core:2.25.2=spotbugs +org.apache.logging.log4j:log4j-api:2.25.5=spotbugs +org.apache.logging.log4j:log4j-core:2.25.5=spotbugs org.apache.maven.doxia:doxia-core:1.12.0=checkstyle org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle org.apache.xbean:xbean-reflect:3.7=checkstyle org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath -org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath +org.assertj:assertj-core:3.27.7=testCompileClasspath,testRuntimeClasspath org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle org.codehaus.plexus:plexus-utils:3.3.0=checkstyle org.dom4j:dom4j:2.2.0=spotbugs org.javassist:javassist:3.28.0-GA=checkstyle -org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,testAnnotationProcessor,testCompileClasspath -org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath -org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-engine:6.0.1=testRuntimeClasspath -org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath -org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath +org.jspecify:jspecify:1.0.1=annotationProcessor,checkstyle,testAnnotationProcessor,testCompileClasspath +org.junit.jupiter:junit-jupiter-api:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.3=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.3=testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.3=testRuntimeClasspath +org.junit:junit-bom:6.0.3=testCompileClasspath,testRuntimeClasspath org.junit:junit-bom:6.1.0=spotbugs org.mockito:mockito-core:5.20.0=mockitoAgent org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath @@ -78,7 +78,8 @@ org.ow2.asm:asm-util:9.10.1=spotbugs org.ow2.asm:asm:9.10.1=spotbugs org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor org.reflections:reflections:0.10.2=checkstyle -org.slf4j:slf4j-api:2.0.17=spotbugs,spotbugsSlf4j -org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j +org.slf4j:slf4j-api:2.0.18=spotbugs,spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.18=checkstyle org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs empty= diff --git a/src/messaging/messaging-security/gradle.lockfile b/src/messaging/messaging-security/gradle.lockfile index 599ff921..e2c95854 100644 --- a/src/messaging/messaging-security/gradle.lockfile +++ b/src/messaging/messaging-security/gradle.lockfile @@ -1,7 +1,7 @@ # This is a Gradle generated file for dependency locking. # Manual edits can break the build and are not advised. # This file is expected to be part of source control. -com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor +com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,testAnnotationProcessor com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs com.github.spotbugs:spotbugs:4.10.2=spotbugs @@ -33,7 +33,7 @@ info.picocli:picocli:4.7.7=checkstyle io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor -jaxen:jaxen:2.0.0=spotbugs +jaxen:jaxen:2.0.6=spotbugs net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs org.antlr:antlr4-runtime:4.13.2=checkstyle @@ -43,30 +43,30 @@ org.apache.commons:commons-text:1.15.0=spotbugs org.apache.commons:commons-text:1.3=checkstyle org.apache.httpcomponents:httpclient:4.5.13=checkstyle org.apache.httpcomponents:httpcore:4.4.16=checkstyle -org.apache.logging.log4j:log4j-api:2.25.2=spotbugs -org.apache.logging.log4j:log4j-core:2.25.2=spotbugs +org.apache.logging.log4j:log4j-api:2.25.5=spotbugs +org.apache.logging.log4j:log4j-core:2.25.5=spotbugs org.apache.maven.doxia:doxia-core:1.12.0=checkstyle org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle org.apache.xbean:xbean-reflect:3.7=checkstyle org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath -org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath +org.assertj:assertj-core:3.27.7=testCompileClasspath,testRuntimeClasspath org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle org.codehaus.plexus:plexus-utils:3.3.0=checkstyle org.dom4j:dom4j:2.2.0=spotbugs org.javassist:javassist:3.28.0-GA=checkstyle -org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,testAnnotationProcessor,testCompileClasspath -org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath -org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-engine:6.0.1=testRuntimeClasspath -org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath -org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath +org.jspecify:jspecify:1.0.1=annotationProcessor,checkstyle,testAnnotationProcessor,testCompileClasspath +org.junit.jupiter:junit-jupiter-api:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.3=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.3=testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.3=testRuntimeClasspath +org.junit:junit-bom:6.0.3=testCompileClasspath,testRuntimeClasspath org.junit:junit-bom:6.1.0=spotbugs org.mockito:mockito-core:5.20.0=mockitoAgent org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath @@ -77,7 +77,8 @@ org.ow2.asm:asm-util:9.10.1=spotbugs org.ow2.asm:asm:9.10.1=spotbugs org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor org.reflections:reflections:0.10.2=checkstyle -org.slf4j:slf4j-api:2.0.17=spotbugs,spotbugsSlf4j -org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j +org.slf4j:slf4j-api:2.0.18=spotbugs,spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.18=checkstyle org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs empty=compileClasspath,runtimeClasspath diff --git a/src/messaging/messaging-spring-boot-starter/gradle.lockfile b/src/messaging/messaging-spring-boot-starter/gradle.lockfile index 020a2ea1..c873ea46 100644 --- a/src/messaging/messaging-spring-boot-starter/gradle.lockfile +++ b/src/messaging/messaging-spring-boot-starter/gradle.lockfile @@ -1,11 +1,12 @@ # This is a Gradle generated file for dependency locking. # Manual edits can break the build and are not advised. # This file is expected to be part of source control. -com.fasterxml.jackson.core:jackson-annotations:2.20=runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor -com.github.docker-java:docker-java-api:3.7.0=testCompileClasspath,testRuntimeClasspath -com.github.docker-java:docker-java-transport-zerodep:3.7.0=testCompileClasspath,testRuntimeClasspath -com.github.docker-java:docker-java-transport:3.7.0=testCompileClasspath,testRuntimeClasspath +at.yawk.lz4:lz4-java:1.10.1=runtimeClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-annotations:2.21=runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,testAnnotationProcessor +com.github.docker-java:docker-java-api:3.7.1=testCompileClasspath,testRuntimeClasspath +com.github.docker-java:docker-java-transport-zerodep:3.7.1=testCompileClasspath,testRuntimeClasspath +com.github.docker-java:docker-java-transport:3.7.1=testCompileClasspath,testRuntimeClasspath com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor com.github.luben:zstd-jni:1.5.6-10=runtimeClasspath,testRuntimeClasspath com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs @@ -37,15 +38,15 @@ commons-codec:commons-codec:1.19.0=testCompileClasspath,testRuntimeClasspath commons-collections:commons-collections:3.2.2=checkstyle commons-io:commons-io:2.20.0=testCompileClasspath,testRuntimeClasspath commons-io:commons-io:2.21.0=spotbugs -commons-logging:commons-logging:1.3.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +commons-logging:commons-logging:1.3.6=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath info.picocli:picocli:4.7.7=checkstyle io.cloudevents:cloudevents-api:4.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.cloudevents:cloudevents-core:4.0.1=runtimeClasspath,testRuntimeClasspath io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor -io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.micrometer:micrometer-core:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-commons:1.16.7=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-core:1.16.7=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-observation:1.16.7=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.netty:netty-buffer:4.2.17.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.netty:netty-codec-base:4.2.17.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.netty:netty-codec-compression:4.2.17.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath @@ -57,10 +58,10 @@ io.netty:netty-handler:4.2.17.Final=compileClasspath,runtimeClasspath,testCompil io.netty:netty-resolver:4.2.17.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.netty:netty-transport-native-unix-common:4.2.17.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.netty:netty-transport:4.2.17.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.projectreactor:reactor-core:3.8.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.projectreactor:reactor-test:3.8.0=testCompileClasspath,testRuntimeClasspath +io.projectreactor:reactor-core:3.8.7=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.projectreactor:reactor-test:3.8.7=testCompileClasspath,testRuntimeClasspath javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor -jaxen:jaxen:2.0.0=spotbugs +jaxen:jaxen:2.0.6=spotbugs net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath net.java.dev.jna:jna:5.18.1=testCompileClasspath,testRuntimeClasspath net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs @@ -72,16 +73,16 @@ org.apache.commons:commons-text:1.15.0=spotbugs org.apache.commons:commons-text:1.3=checkstyle org.apache.httpcomponents:httpclient:4.5.13=checkstyle org.apache.httpcomponents:httpcore:4.4.16=checkstyle -org.apache.kafka:kafka-clients:4.1.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.apache.logging.log4j:log4j-api:2.25.2=spotbugs -org.apache.logging.log4j:log4j-core:2.25.2=spotbugs +org.apache.kafka:kafka-clients:4.1.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-api:2.25.5=spotbugs +org.apache.logging.log4j:log4j-core:2.25.5=spotbugs org.apache.maven.doxia:doxia-core:1.12.0=checkstyle org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle org.apache.xbean:xbean-reflect:3.7=checkstyle org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath -org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath +org.assertj:assertj-core:3.27.7=testCompileClasspath,testRuntimeClasspath org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle @@ -90,18 +91,17 @@ org.dom4j:dom4j:2.2.0=spotbugs org.hdrhistogram:HdrHistogram:2.2.2=runtimeClasspath,testRuntimeClasspath org.javassist:javassist:3.28.0-GA=checkstyle org.jetbrains:annotations:17.0.0=testCompileClasspath,testRuntimeClasspath -org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath -org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-engine:6.0.1=testRuntimeClasspath -org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath -org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath +org.jspecify:jspecify:1.0.1=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.3=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.3=testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.3=testRuntimeClasspath +org.junit:junit-bom:6.0.3=testCompileClasspath,testRuntimeClasspath org.junit:junit-bom:6.1.0=spotbugs org.latencyutils:LatencyUtils:2.0.3=runtimeClasspath,testRuntimeClasspath -org.lz4:lz4-java:1.8.0=runtimeClasspath,testRuntimeClasspath org.mockito:mockito-core:5.20.0=mockitoAgent org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath org.ow2.asm:asm-analysis:9.10.1=spotbugs @@ -113,32 +113,33 @@ org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor org.reactivestreams:reactive-streams:1.0.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.reflections:reflections:0.10.2=checkstyle org.rnorth.duct-tape:duct-tape:1.0.8=testCompileClasspath,testRuntimeClasspath -org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath -org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j -org.springframework.amqp:spring-amqp:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.amqp:spring-rabbit:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-actuator:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-configuration-processor:4.0.0=annotationProcessor -org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.kafka:spring-kafka:4.0.0=runtimeClasspath,testRuntimeClasspath -org.springframework:spring-aop:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-beans:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-context:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-core:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-expression:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-jdbc:7.0.1=runtimeClasspath,testRuntimeClasspath -org.springframework:spring-messaging:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-test:7.0.1=testCompileClasspath,testRuntimeClasspath -org.springframework:spring-tx:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.testcontainers:testcontainers-junit-jupiter:2.0.2=testCompileClasspath,testRuntimeClasspath -org.testcontainers:testcontainers-kafka:2.0.2=testCompileClasspath,testRuntimeClasspath -org.testcontainers:testcontainers:2.0.2=testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-api:2.0.18=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.18=checkstyle +org.springframework.amqp:spring-amqp:4.0.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.amqp:spring-rabbit:4.0.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-actuator:4.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-autoconfigure:4.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-configuration-processor:4.0.8=annotationProcessor +org.springframework.boot:spring-boot-test:4.0.8=testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot:4.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.kafka:spring-kafka:4.0.7=runtimeClasspath,testRuntimeClasspath +org.springframework:spring-aop:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-beans:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-context:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-core:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-expression:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-jdbc:7.0.9=runtimeClasspath,testRuntimeClasspath +org.springframework:spring-messaging:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-test:7.0.9=testCompileClasspath,testRuntimeClasspath +org.springframework:spring-tx:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.testcontainers:testcontainers-junit-jupiter:2.0.5=testCompileClasspath,testRuntimeClasspath +org.testcontainers:testcontainers-kafka:2.0.5=testCompileClasspath,testRuntimeClasspath +org.testcontainers:testcontainers:2.0.5=testCompileClasspath,testRuntimeClasspath org.xerial.snappy:snappy-java:1.1.10.7=runtimeClasspath,testRuntimeClasspath org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs org.yaml:snakeyaml:2.5=testCompileClasspath,testRuntimeClasspath -tools.jackson.core:jackson-core:3.0.2=runtimeClasspath,testRuntimeClasspath -tools.jackson.core:jackson-databind:3.0.2=runtimeClasspath,testRuntimeClasspath -tools.jackson:jackson-bom:3.0.2=runtimeClasspath,testRuntimeClasspath +tools.jackson.core:jackson-core:3.1.5=runtimeClasspath,testRuntimeClasspath +tools.jackson.core:jackson-databind:3.1.5=runtimeClasspath,testRuntimeClasspath +tools.jackson:jackson-bom:3.1.5=runtimeClasspath,testRuntimeClasspath empty= diff --git a/src/messaging/messaging-spring-cloud-stream-bridge/gradle.lockfile b/src/messaging/messaging-spring-cloud-stream-bridge/gradle.lockfile index 81b3df37..4c95da8a 100644 --- a/src/messaging/messaging-spring-cloud-stream-bridge/gradle.lockfile +++ b/src/messaging/messaging-spring-cloud-stream-bridge/gradle.lockfile @@ -1,7 +1,7 @@ # This is a Gradle generated file for dependency locking. # Manual edits can break the build and are not advised. # This file is expected to be part of source control. -com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor +com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,testAnnotationProcessor com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs com.github.spotbugs:spotbugs:4.10.2=spotbugs @@ -29,14 +29,14 @@ com.puppycrawl.tools:checkstyle:13.5.0=checkstyle commons-beanutils:commons-beanutils:1.11.0=checkstyle commons-collections:commons-collections:3.2.2=checkstyle commons-io:commons-io:2.21.0=spotbugs -commons-logging:commons-logging:1.3.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +commons-logging:commons-logging:1.3.6=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath info.picocli:picocli:4.7.7=checkstyle io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor -io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-commons:1.16.7=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-observation:1.16.7=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor -jaxen:jaxen:2.0.0=spotbugs +jaxen:jaxen:2.0.6=spotbugs net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs org.antlr:antlr4-runtime:4.13.2=checkstyle @@ -46,30 +46,30 @@ org.apache.commons:commons-text:1.15.0=spotbugs org.apache.commons:commons-text:1.3=checkstyle org.apache.httpcomponents:httpclient:4.5.13=checkstyle org.apache.httpcomponents:httpcore:4.4.16=checkstyle -org.apache.logging.log4j:log4j-api:2.25.2=spotbugs -org.apache.logging.log4j:log4j-core:2.25.2=spotbugs +org.apache.logging.log4j:log4j-api:2.25.5=spotbugs +org.apache.logging.log4j:log4j-core:2.25.5=spotbugs org.apache.maven.doxia:doxia-core:1.12.0=checkstyle org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle org.apache.xbean:xbean-reflect:3.7=checkstyle org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath -org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath +org.assertj:assertj-core:3.27.7=testCompileClasspath,testRuntimeClasspath org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle org.codehaus.plexus:plexus-utils:3.3.0=checkstyle org.dom4j:dom4j:2.2.0=spotbugs org.javassist:javassist:3.28.0-GA=checkstyle -org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath -org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-engine:6.0.1=testRuntimeClasspath -org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath -org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath +org.jspecify:jspecify:1.0.1=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.3=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.3=testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.3=testRuntimeClasspath +org.junit:junit-bom:6.0.3=testCompileClasspath,testRuntimeClasspath org.junit:junit-bom:6.1.0=spotbugs org.mockito:mockito-core:5.20.0=mockitoAgent org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath @@ -80,12 +80,13 @@ org.ow2.asm:asm-util:9.10.1=spotbugs org.ow2.asm:asm:9.10.1=spotbugs org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor org.reflections:reflections:0.10.2=checkstyle -org.slf4j:slf4j-api:2.0.17=spotbugs,spotbugsSlf4j -org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j -org.springframework:spring-aop:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-beans:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-context:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-core:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-expression:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-api:2.0.18=spotbugs,spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.18=checkstyle +org.springframework:spring-aop:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-beans:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-context:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-core:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-expression:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs empty= diff --git a/src/messaging/messaging-testkit/gradle.lockfile b/src/messaging/messaging-testkit/gradle.lockfile index d1ba4a93..98b1f9db 100644 --- a/src/messaging/messaging-testkit/gradle.lockfile +++ b/src/messaging/messaging-testkit/gradle.lockfile @@ -1,7 +1,7 @@ # This is a Gradle generated file for dependency locking. # Manual edits can break the build and are not advised. # This file is expected to be part of source control. -com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor +com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs com.github.spotbugs:spotbugs:4.10.2=spotbugs @@ -33,7 +33,7 @@ info.picocli:picocli:4.7.7=checkstyle io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor javax.inject:javax.inject:1=annotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor -jaxen:jaxen:2.0.0=spotbugs +jaxen:jaxen:2.0.6=spotbugs net.bytebuddy:byte-buddy:1.17.8=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath net.sf.jopt-simple:jopt-simple:5.0.4=jmhAnnotationProcessor,jmhCompileClasspath,jmhRuntimeClasspath net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs @@ -45,30 +45,30 @@ org.apache.commons:commons-text:1.15.0=spotbugs org.apache.commons:commons-text:1.3=checkstyle org.apache.httpcomponents:httpclient:4.5.13=checkstyle org.apache.httpcomponents:httpcore:4.4.16=checkstyle -org.apache.logging.log4j:log4j-api:2.25.2=spotbugs -org.apache.logging.log4j:log4j-core:2.25.2=spotbugs +org.apache.logging.log4j:log4j-api:2.25.5=spotbugs +org.apache.logging.log4j:log4j-core:2.25.5=spotbugs org.apache.maven.doxia:doxia-core:1.12.0=checkstyle org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle org.apache.xbean:xbean-reflect:3.7=checkstyle org.apiguardian:apiguardian-api:1.1.2=compileClasspath,jmhCompileClasspath,testCompileClasspath -org.assertj:assertj-core:3.27.6=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.assertj:assertj-core:3.27.7=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle org.codehaus.plexus:plexus-utils:3.3.0=checkstyle org.dom4j:dom4j:2.2.0=spotbugs org.javassist:javassist:3.28.0-GA=checkstyle -org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,jmhAnnotationProcessor,jmhCompileClasspath,testAnnotationProcessor,testCompileClasspath -org.junit.jupiter:junit-jupiter-api:6.0.1=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-engine:6.0.1=jmhRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-params:6.0.1=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter:6.0.1=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-commons:6.0.1=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-engine:6.0.1=jmhRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-launcher:6.0.1=jmhRuntimeClasspath,testRuntimeClasspath -org.junit:junit-bom:6.0.1=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.jspecify:jspecify:1.0.1=annotationProcessor,checkstyle,compileClasspath,jmhAnnotationProcessor,jmhCompileClasspath,testAnnotationProcessor,testCompileClasspath +org.junit.jupiter:junit-jupiter-api:6.0.3=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.3=jmhRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.3=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.3=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.3=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.3=jmhRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.3=jmhRuntimeClasspath,testRuntimeClasspath +org.junit:junit-bom:6.0.3=compileClasspath,jmhCompileClasspath,jmhRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.junit:junit-bom:6.1.0=spotbugs org.mockito:mockito-core:5.20.0=mockitoAgent org.openjdk.jmh:jmh-core:1.37=jmhAnnotationProcessor,jmhCompileClasspath,jmhRuntimeClasspath @@ -81,7 +81,8 @@ org.ow2.asm:asm-util:9.10.1=spotbugs org.ow2.asm:asm:9.10.1=spotbugs org.pcollections:pcollections:4.0.1=annotationProcessor,jmhAnnotationProcessor,testAnnotationProcessor org.reflections:reflections:0.10.2=checkstyle -org.slf4j:slf4j-api:2.0.17=spotbugs,spotbugsSlf4j -org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j +org.slf4j:slf4j-api:2.0.18=spotbugs,spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.18=checkstyle org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs empty= diff --git a/src/messaging/messaging-transport-spi/gradle.lockfile b/src/messaging/messaging-transport-spi/gradle.lockfile index 599ff921..e2c95854 100644 --- a/src/messaging/messaging-transport-spi/gradle.lockfile +++ b/src/messaging/messaging-transport-spi/gradle.lockfile @@ -1,7 +1,7 @@ # This is a Gradle generated file for dependency locking. # Manual edits can break the build and are not advised. # This file is expected to be part of source control. -com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor +com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,testAnnotationProcessor com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs com.github.spotbugs:spotbugs:4.10.2=spotbugs @@ -33,7 +33,7 @@ info.picocli:picocli:4.7.7=checkstyle io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor -jaxen:jaxen:2.0.0=spotbugs +jaxen:jaxen:2.0.6=spotbugs net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs org.antlr:antlr4-runtime:4.13.2=checkstyle @@ -43,30 +43,30 @@ org.apache.commons:commons-text:1.15.0=spotbugs org.apache.commons:commons-text:1.3=checkstyle org.apache.httpcomponents:httpclient:4.5.13=checkstyle org.apache.httpcomponents:httpcore:4.4.16=checkstyle -org.apache.logging.log4j:log4j-api:2.25.2=spotbugs -org.apache.logging.log4j:log4j-core:2.25.2=spotbugs +org.apache.logging.log4j:log4j-api:2.25.5=spotbugs +org.apache.logging.log4j:log4j-core:2.25.5=spotbugs org.apache.maven.doxia:doxia-core:1.12.0=checkstyle org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle org.apache.xbean:xbean-reflect:3.7=checkstyle org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath -org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath +org.assertj:assertj-core:3.27.7=testCompileClasspath,testRuntimeClasspath org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle org.codehaus.plexus:plexus-utils:3.3.0=checkstyle org.dom4j:dom4j:2.2.0=spotbugs org.javassist:javassist:3.28.0-GA=checkstyle -org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,testAnnotationProcessor,testCompileClasspath -org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath -org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-engine:6.0.1=testRuntimeClasspath -org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath -org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath +org.jspecify:jspecify:1.0.1=annotationProcessor,checkstyle,testAnnotationProcessor,testCompileClasspath +org.junit.jupiter:junit-jupiter-api:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.3=testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.3=testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.3=testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.3=testRuntimeClasspath +org.junit:junit-bom:6.0.3=testCompileClasspath,testRuntimeClasspath org.junit:junit-bom:6.1.0=spotbugs org.mockito:mockito-core:5.20.0=mockitoAgent org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath @@ -77,7 +77,8 @@ org.ow2.asm:asm-util:9.10.1=spotbugs org.ow2.asm:asm:9.10.1=spotbugs org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor org.reflections:reflections:0.10.2=checkstyle -org.slf4j:slf4j-api:2.0.17=spotbugs,spotbugsSlf4j -org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j +org.slf4j:slf4j-api:2.0.18=spotbugs,spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.18=checkstyle org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs empty=compileClasspath,runtimeClasspath diff --git a/src/messaging/messaging-transport-spi/src/test/java/dev/caskeleton/messaging/transport/MessagingRuntimeRegistryTest.java b/src/messaging/messaging-transport-spi/src/test/java/dev/caskeleton/messaging/transport/MessagingRuntimeRegistryTest.java index 8f532c48..3cb35972 100644 --- a/src/messaging/messaging-transport-spi/src/test/java/dev/caskeleton/messaging/transport/MessagingRuntimeRegistryTest.java +++ b/src/messaging/messaging-transport-spi/src/test/java/dev/caskeleton/messaging/transport/MessagingRuntimeRegistryTest.java @@ -16,154 +16,165 @@ class MessagingRuntimeRegistryTest { @Test void replacingRuntimeReturnsNewGenerationAndKeepsOldUntilReleased() { - DefaultMessagingRuntimeRegistry registry = new DefaultMessagingRuntimeRegistry(); - MessagingRuntime first = MessagingRuntimeFixtures.runtime("kafka-primary", 1); - MessagingRuntime second = MessagingRuntimeFixtures.runtime("kafka-primary", 2); + try (DefaultMessagingRuntimeRegistry registry = new DefaultMessagingRuntimeRegistry()) { + MessagingRuntime first = MessagingRuntimeFixtures.runtime("kafka-primary", 1); + MessagingRuntime second = MessagingRuntimeFixtures.runtime("kafka-primary", 2); - registry.install(first); - MessagingRuntimeLease lease = registry.acquire("kafka-primary"); - registry.install(second); + registry.install(first); + MessagingRuntimeLease lease = registry.acquire("kafka-primary"); + registry.install(second); - assertThat(lease.runtime().generation()).isEqualTo(1); - assertThat(first.isClosed()).as("a leased generation stays open").isFalse(); - assertThat(registry.acquire("kafka-primary").runtime().generation()).isEqualTo(2); + assertThat(lease.runtime().generation()).isEqualTo(1); + assertThat(first.isClosed()).as("a leased generation stays open").isFalse(); + assertThat(registry.acquire("kafka-primary").runtime().generation()).isEqualTo(2); - lease.close(); + lease.close(); - assertThat(first.isClosed()).isTrue(); + assertThat(first.isClosed()).isTrue(); + } } @Test void anIdleGenerationIsClosedImmediatelyOnReplacement() { - DefaultMessagingRuntimeRegistry registry = new DefaultMessagingRuntimeRegistry(); - MessagingRuntime first = MessagingRuntimeFixtures.runtime("kafka-primary", 1); + try (DefaultMessagingRuntimeRegistry registry = new DefaultMessagingRuntimeRegistry()) { + MessagingRuntime first = MessagingRuntimeFixtures.runtime("kafka-primary", 1); - registry.install(first); - registry.install(MessagingRuntimeFixtures.runtime("kafka-primary", 2)); + registry.install(first); + registry.install(MessagingRuntimeFixtures.runtime("kafka-primary", 2)); - assertThat(first.isClosed()).isTrue(); - assertThat(registry.drainingCount()).isZero(); + assertThat(first.isClosed()).isTrue(); + assertThat(registry.drainingCount()).isZero(); + } } @Test void closingTheRegistryClosesCurrentAndDrainingGenerationsExactlyOnce() { - DefaultMessagingRuntimeRegistry registry = - new DefaultMessagingRuntimeRegistry(Duration.ofSeconds(30)); - CountingRuntime retired = new CountingRuntime("kafka-primary", 1); - CountingRuntime current = new CountingRuntime("kafka-primary", 2); + try (DefaultMessagingRuntimeRegistry registry = + new DefaultMessagingRuntimeRegistry(Duration.ofSeconds(30))) { + CountingRuntime retired = new CountingRuntime("kafka-primary", 1); + CountingRuntime current = new CountingRuntime("kafka-primary", 2); - registry.install(retired); - MessagingRuntimeLease leaked = registry.acquire("kafka-primary"); - registry.install(current, Instant.parse("2026-08-10T00:00:00Z")); + registry.install(retired); + MessagingRuntimeLease leaked = registry.acquire("kafka-primary"); + registry.install(current, Instant.parse("2026-08-10T00:00:00Z")); - registry.close(); - registry.close(); + registry.close(); + registry.close(); - assertThat(current.closes()) - .as("a process that shut down without rotating left its connections to JVM exit") - .isEqualTo(1); - assertThat(retired.closes()) - .as("a second close on a broker client is where 'already closed' at shutdown comes from") - .isEqualTo(1); + assertThat(current.closes()) + .as("a process that shut down without rotating left its connections to JVM exit") + .isEqualTo(1); + assertThat(retired.closes()) + .as("a second close on a broker client is where 'already closed' at shutdown comes from") + .isEqualTo(1); - leaked.close(); + leaked.close(); + } } @Test void closingALeaseTwiceDoesNotDoubleRelease() { - DefaultMessagingRuntimeRegistry registry = new DefaultMessagingRuntimeRegistry(); - MessagingRuntime first = MessagingRuntimeFixtures.runtime("kafka-primary", 1); - registry.install(first); + try (DefaultMessagingRuntimeRegistry registry = new DefaultMessagingRuntimeRegistry()) { + MessagingRuntime first = MessagingRuntimeFixtures.runtime("kafka-primary", 1); + registry.install(first); - MessagingRuntimeLease lease = registry.acquire("kafka-primary"); - lease.close(); - lease.close(); + MessagingRuntimeLease lease = registry.acquire("kafka-primary"); + lease.close(); + lease.close(); - assertThat(first.isClosed()).as("the current generation is not retired").isFalse(); + assertThat(first.isClosed()).as("the current generation is not retired").isFalse(); + } } @Test void theDrainDeadlineForceClosesALeakedLease() { - DefaultMessagingRuntimeRegistry registry = - new DefaultMessagingRuntimeRegistry(Duration.ofSeconds(30)); - MessagingRuntime first = MessagingRuntimeFixtures.runtime("kafka-primary", 1); + try (DefaultMessagingRuntimeRegistry registry = + new DefaultMessagingRuntimeRegistry(Duration.ofSeconds(30))) { + MessagingRuntime first = MessagingRuntimeFixtures.runtime("kafka-primary", 1); - Instant retiredAt = Instant.parse("2026-08-10T00:00:00Z"); - registry.install(first); - MessagingRuntimeLease leaked = registry.acquire("kafka-primary"); - // The rotation stamps this generation's own retirement time, so the deadline below is measured - // from when *it* retired rather than from whatever instant the caller happened to pass. - registry.install(MessagingRuntimeFixtures.runtime("kafka-primary", 2), retiredAt); + Instant retiredAt = Instant.parse("2026-08-10T00:00:00Z"); + registry.install(first); + MessagingRuntimeLease leaked = registry.acquire("kafka-primary"); + // The rotation stamps this generation's own retirement time, so the deadline below is + // measured + // from when *it* retired rather than from whatever instant the caller happened to pass. + registry.install(MessagingRuntimeFixtures.runtime("kafka-primary", 2), retiredAt); - assertThat(registry.closeExpiredDraining(retiredAt.plusSeconds(29))).isZero(); - assertThat(first.isClosed()).isFalse(); + assertThat(registry.closeExpiredDraining(retiredAt.plusSeconds(29))).isZero(); + assertThat(first.isClosed()).isFalse(); - assertThat(registry.closeExpiredDraining(retiredAt.plusSeconds(30))).isEqualTo(1); - assertThat(first.isClosed()).isTrue(); + assertThat(registry.closeExpiredDraining(retiredAt.plusSeconds(30))).isEqualTo(1); + assertThat(first.isClosed()).isTrue(); - leaked.close(); + leaked.close(); + } } @Test void acquiringAnUninstalledBrokerIsAConfigurationFailure() { - DefaultMessagingRuntimeRegistry registry = new DefaultMessagingRuntimeRegistry(); + try (DefaultMessagingRuntimeRegistry registry = new DefaultMessagingRuntimeRegistry()) { - assertThatThrownBy(() -> registry.acquire("rabbit-primary")) - .isInstanceOf(MessagingConfigurationException.class); + assertThatThrownBy(() -> registry.acquire("rabbit-primary")) + .isInstanceOf(MessagingConfigurationException.class); + } } @Test void generationsAreTrackedPerBroker() { - DefaultMessagingRuntimeRegistry registry = new DefaultMessagingRuntimeRegistry(); - registry.install(MessagingRuntimeFixtures.runtime("kafka-primary", 7)); - registry.install(MessagingRuntimeFixtures.runtime("rabbit-primary", 3)); + try (DefaultMessagingRuntimeRegistry registry = new DefaultMessagingRuntimeRegistry()) { + registry.install(MessagingRuntimeFixtures.runtime("kafka-primary", 7)); + registry.install(MessagingRuntimeFixtures.runtime("rabbit-primary", 3)); - assertThat(registry.acquire("kafka-primary").runtime().generation()).isEqualTo(7); - assertThat(registry.acquire("rabbit-primary").runtime().generation()).isEqualTo(3); + assertThat(registry.acquire("kafka-primary").runtime().generation()).isEqualTo(7); + assertThat(registry.acquire("rabbit-primary").runtime().generation()).isEqualTo(3); + } } @Test @org.junit.jupiter.api.DisplayName("each retired generation has its own drain deadline") void eachGenerationHasItsOwnDeadline() { - DefaultMessagingRuntimeRegistry registry = - new DefaultMessagingRuntimeRegistry(Duration.ofSeconds(30)); - Instant first = Instant.parse("2026-08-10T00:00:00Z"); - MessagingRuntime one = MessagingRuntimeFixtures.runtime("kafka-primary", 1); - MessagingRuntime two = MessagingRuntimeFixtures.runtime("kafka-primary", 2); + try (DefaultMessagingRuntimeRegistry registry = + new DefaultMessagingRuntimeRegistry(Duration.ofSeconds(30))) { + Instant first = Instant.parse("2026-08-10T00:00:00Z"); + MessagingRuntime one = MessagingRuntimeFixtures.runtime("kafka-primary", 1); + MessagingRuntime two = MessagingRuntimeFixtures.runtime("kafka-primary", 2); - registry.install(one); - MessagingRuntimeLease leakedOne = registry.acquire("kafka-primary"); - registry.install(two, first); - MessagingRuntimeLease leakedTwo = registry.acquire("kafka-primary"); - registry.install(MessagingRuntimeFixtures.runtime("kafka-primary", 3), first.plusSeconds(20)); + registry.install(one); + MessagingRuntimeLease leakedOne = registry.acquire("kafka-primary"); + registry.install(two, first); + MessagingRuntimeLease leakedTwo = registry.acquire("kafka-primary"); + registry.install(MessagingRuntimeFixtures.runtime("kafka-primary", 3), first.plusSeconds(20)); - // 35s after the first retirement, 15s after the second. - assertThat(registry.closeExpiredDraining(first.plusSeconds(35))) - .as("one caller-supplied timestamp for the whole list closed both or neither") - .isEqualTo(1); - assertThat(one.isClosed()).isTrue(); - assertThat(two.isClosed()).isFalse(); + // 35s after the first retirement, 15s after the second. + assertThat(registry.closeExpiredDraining(first.plusSeconds(35))) + .as("one caller-supplied timestamp for the whole list closed both or neither") + .isEqualTo(1); + assertThat(one.isClosed()).isTrue(); + assertThat(two.isClosed()).isFalse(); - leakedOne.close(); - leakedTwo.close(); + leakedOne.close(); + leakedTwo.close(); + } } @Test @org.junit.jupiter.api.DisplayName("a generation that closed on its own leaves the draining list") void anIdleClosedGenerationLeavesTheDrainingList() { - DefaultMessagingRuntimeRegistry registry = - new DefaultMessagingRuntimeRegistry(Duration.ofSeconds(30)); - Instant retiredAt = Instant.parse("2026-08-10T00:00:00Z"); - registry.install(MessagingRuntimeFixtures.runtime("kafka-primary", 1)); - MessagingRuntimeLease lease = registry.acquire("kafka-primary"); - registry.install(MessagingRuntimeFixtures.runtime("kafka-primary", 2), retiredAt); + try (DefaultMessagingRuntimeRegistry registry = + new DefaultMessagingRuntimeRegistry(Duration.ofSeconds(30))) { + Instant retiredAt = Instant.parse("2026-08-10T00:00:00Z"); + registry.install(MessagingRuntimeFixtures.runtime("kafka-primary", 1)); + MessagingRuntimeLease lease = registry.acquire("kafka-primary"); + registry.install(MessagingRuntimeFixtures.runtime("kafka-primary", 2), retiredAt); - lease.close(); - registry.closeExpiredDraining(retiredAt.plusSeconds(1)); + lease.close(); + registry.closeExpiredDraining(retiredAt.plusSeconds(1)); - assertThat(registry.drainingCount()) - .as("a closed generation counted as draining forever, so the metric never returned to zero") - .isZero(); + assertThat(registry.drainingCount()) + .as( + "a closed generation counted as draining forever, so the metric never returned to zero") + .isZero(); + } } } diff --git a/src/messaging/messaging-transport-spi/src/test/java/dev/caskeleton/messaging/transport/ResourceLeakGateTest.java b/src/messaging/messaging-transport-spi/src/test/java/dev/caskeleton/messaging/transport/ResourceLeakGateTest.java index 5c07d468..e95c14da 100644 --- a/src/messaging/messaging-transport-spi/src/test/java/dev/caskeleton/messaging/transport/ResourceLeakGateTest.java +++ b/src/messaging/messaging-transport-spi/src/test/java/dev/caskeleton/messaging/transport/ResourceLeakGateTest.java @@ -23,37 +23,39 @@ class ResourceLeakGateTest { @Test void everyRetiredGenerationIsEventuallyClosed() { - DefaultMessagingRuntimeRegistry registry = - new DefaultMessagingRuntimeRegistry(Duration.ofSeconds(30)); - List generations = new ArrayList<>(); + try (DefaultMessagingRuntimeRegistry registry = + new DefaultMessagingRuntimeRegistry(Duration.ofSeconds(30))) { + List generations = new ArrayList<>(); - for (int generation = 1; generation <= 20; generation++) { - LeakTrackingRuntime runtime = new LeakTrackingRuntime("kafka-primary", generation); - generations.add(runtime); - registry.install(runtime); - registry.acquire("kafka-primary").close(); + for (int generation = 1; generation <= 20; generation++) { + LeakTrackingRuntime runtime = new LeakTrackingRuntime("kafka-primary", generation); + generations.add(runtime); + registry.install(runtime); + registry.acquire("kafka-primary").close(); + } + + assertThat(generations.subList(0, generations.size() - 1)) + .as("only the current generation stays open") + .allSatisfy(runtime -> assertThat(runtime.isClosed()).isTrue()); + assertThat(registry.drainingCount()).isZero(); } - - assertThat(generations.subList(0, generations.size() - 1)) - .as("only the current generation stays open") - .allSatisfy(runtime -> assertThat(runtime.isClosed()).isTrue()); - assertThat(registry.drainingCount()).isZero(); } @Test void aLeakedLeaseIsForceClosedAtTheDrainDeadline() { - DefaultMessagingRuntimeRegistry registry = - new DefaultMessagingRuntimeRegistry(Duration.ofSeconds(30)); - LeakTrackingRuntime retired = new LeakTrackingRuntime("kafka-primary", 1); - registry.install(retired); - registry.acquire("kafka-primary"); + try (DefaultMessagingRuntimeRegistry registry = + new DefaultMessagingRuntimeRegistry(Duration.ofSeconds(30))) { + LeakTrackingRuntime retired = new LeakTrackingRuntime("kafka-primary", 1); + registry.install(retired); + registry.acquire("kafka-primary"); - registry.install(new LeakTrackingRuntime("kafka-primary", 2), NOW); - registry.closeExpiredDraining(NOW.plusSeconds(30)); + registry.install(new LeakTrackingRuntime("kafka-primary", 2), NOW); + registry.closeExpiredDraining(NOW.plusSeconds(30)); - assertThat(retired.isClosed()) - .as("a leaked lease must not pin a revoked credential open forever") - .isTrue(); + assertThat(retired.isClosed()) + .as("a leaked lease must not pin a revoked credential open forever") + .isTrue(); + } } @Test @@ -70,20 +72,21 @@ class ResourceLeakGateTest { @Test void aRetiredGenerationIsClosedExactlyOnce() { - DefaultMessagingRuntimeRegistry registry = - new DefaultMessagingRuntimeRegistry(Duration.ofSeconds(30)); - LeakTrackingRuntime retired = new LeakTrackingRuntime("kafka-primary", 1); - registry.install(retired); - MessagingRuntimeLease lease = registry.acquire("kafka-primary"); - registry.install(new LeakTrackingRuntime("kafka-primary", 2), NOW); + try (DefaultMessagingRuntimeRegistry registry = + new DefaultMessagingRuntimeRegistry(Duration.ofSeconds(30))) { + LeakTrackingRuntime retired = new LeakTrackingRuntime("kafka-primary", 1); + registry.install(retired); + MessagingRuntimeLease lease = registry.acquire("kafka-primary"); + registry.install(new LeakTrackingRuntime("kafka-primary", 2), NOW); - lease.close(); - lease.close(); - registry.closeExpiredDraining(NOW.plusSeconds(60)); + lease.close(); + lease.close(); + registry.closeExpiredDraining(NOW.plusSeconds(60)); - assertThat(retired.closeCount()) - .as("a second close on a real connection pool throws from a shutdown hook") - .isEqualTo(1); + assertThat(retired.closeCount()) + .as("a second close on a real connection pool throws from a shutdown hook") + .isEqualTo(1); + } } } diff --git a/src/sample-portfolio/build.gradle b/src/sample-portfolio/build.gradle index 5f56636e..ccf1db0f 100644 --- a/src/sample-portfolio/build.gradle +++ b/src/sample-portfolio/build.gradle @@ -80,6 +80,10 @@ dependencies { testRuntimeOnly 'org.postgresql:postgresql' // Spring Security test support (MockMvc + @WithMockUser) for any slice tests. testImplementation 'org.springframework.security:spring-security-test' + // TestRestTemplate needs RestTemplateBuilder, and spring-boot-resttestclient stopped + // bringing it transitively in Spring Boot 4.0.x — the capability is still supported, its + // dependency is simply no longer implicit. A module that autowires TestRestTemplate says so. + testImplementation 'org.springframework.boot:spring-boot-restclient' } // feature-contract-verification-test-suite §5 — OpenAPI drift gate. diff --git a/src/sample-portfolio/gradle.lockfile b/src/sample-portfolio/gradle.lockfile index 0645f37b..a2915693 100644 --- a/src/sample-portfolio/gradle.lockfile +++ b/src/sample-portfolio/gradle.lockfile @@ -3,29 +3,28 @@ # This file is expected to be part of source control. aopalliance:aopalliance:1.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,posterImageMigrationTestCompileClasspath,testCompileClasspath -ch.qos.logback:logback-classic:1.5.21=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -ch.qos.logback:logback-core:1.5.21=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.fasterxml.jackson.core:jackson-annotations:2.20=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.fasterxml.jackson.core:jackson-core:2.20.1=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.fasterxml.jackson.core:jackson-databind:2.20.1=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.20.1=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.20.1=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -com.fasterxml.jackson:jackson-bom:2.20.1=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.fasterxml:classmate:1.7.1=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,posterImageMigrationTestAnnotationProcessor,testAnnotationProcessor -com.github.docker-java:docker-java-api:3.7.0=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -com.github.docker-java:docker-java-transport-zerodep:3.7.0=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -com.github.docker-java:docker-java-transport:3.7.0=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +ch.qos.logback:logback-classic:1.5.38=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +ch.qos.logback:logback-core:1.5.38=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-annotations:2.21=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-core:2.21.5=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.core:jackson-databind:2.21.5=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.21.5=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.21.5=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +com.fasterxml.jackson:jackson-bom:2.21.5=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.fasterxml:classmate:1.7.3=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,posterImageMigrationTestAnnotationProcessor,testAnnotationProcessor +com.github.docker-java:docker-java-api:3.7.1=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.github.docker-java:docker-java-transport-zerodep:3.7.1=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.github.docker-java:docker-java-transport:3.7.1=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.github.f4b6a3:uuid-creator:6.1.1=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,posterImageMigrationTestAnnotationProcessor,testAnnotationProcessor com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs -com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,posterImageMigrationTestCompileClasspath,testCompileClasspath com.github.spotbugs:spotbugs:4.10.2=spotbugs com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,posterImageMigrationTestAnnotationProcessor,testAnnotationProcessor com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,posterImageMigrationTestAnnotationProcessor,testAnnotationProcessor com.google.auto:auto-common:1.2.2=annotationProcessor,posterImageMigrationTestAnnotationProcessor,testAnnotationProcessor -com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,posterImageMigrationTestCompileClasspath,spotbugs,testCompileClasspath +com.google.code.findbugs:jsr305:3.0.2=checkstyle,spotbugs com.google.code.gson:gson:2.13.2=spotbugs com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,posterImageMigrationTestAnnotationProcessor,testAnnotationProcessor com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,posterImageMigrationTestCompileClasspath,testCompileClasspath @@ -43,7 +42,7 @@ com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,posterIm com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,posterImageMigrationTestAnnotationProcessor,testAnnotationProcessor com.h2database:h2:2.4.240=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins -com.jayway.jsonpath:json-path:2.9.0=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +com.jayway.jsonpath:json-path:2.10.0=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath com.nimbusds:nimbus-jose-jwt:10.4=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath com.puppycrawl.tools:checkstyle:13.5.0=checkstyle com.squareup.okhttp3:okhttp-jvm:5.2.1=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath @@ -58,19 +57,19 @@ commons-codec:commons-codec:1.19.0=posterImageMigrationTestCompileClasspath,post commons-collections:commons-collections:3.2.2=checkstyle commons-io:commons-io:2.20.0=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath commons-io:commons-io:2.21.0=spotbugs -commons-logging:commons-logging:1.3.5=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +commons-logging:commons-logging:1.3.6=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath info.picocli:picocli:4.7.7=checkstyle io.github.cdimascio:dotenv-java:3.0.0=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,posterImageMigrationTestAnnotationProcessor,testAnnotationProcessor io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,posterImageMigrationTestAnnotationProcessor,testAnnotationProcessor -io.micrometer:context-propagation:1.2.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.micrometer:micrometer-commons:1.16.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.micrometer:micrometer-core:1.16.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.micrometer:micrometer-jakarta9:1.16.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.micrometer:micrometer-observation:1.16.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.micrometer:micrometer-registry-prometheus:1.16.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.micrometer:micrometer-tracing-bridge-otel:1.6.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.micrometer:micrometer-tracing:1.6.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:context-propagation:1.2.1=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-commons:1.16.7=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-core:1.16.7=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-jakarta9:1.16.7=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-observation:1.16.7=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-registry-prometheus:1.16.7=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-tracing-bridge-otel:1.6.7=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +io.micrometer:micrometer-tracing:1.6.7=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.netty:netty-buffer:4.2.17.Final=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath io.netty:netty-codec-base:4.2.17.Final=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath io.netty:netty-codec-compression:4.2.17.Final=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath @@ -100,7 +99,7 @@ io.opentelemetry:opentelemetry-sdk-logs:1.55.0=compileClasspath,posterImageMigra io.opentelemetry:opentelemetry-sdk-metrics:1.55.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.opentelemetry:opentelemetry-sdk-trace:1.55.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.opentelemetry:opentelemetry-sdk:1.55.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -io.projectreactor:reactor-core:3.8.0=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +io.projectreactor:reactor-core:3.8.7=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath io.prometheus:prometheus-metrics-config:1.4.3=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.prometheus:prometheus-metrics-core:1.4.3=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath io.prometheus:prometheus-metrics-exposition-formats:1.4.3=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath @@ -116,9 +115,9 @@ jakarta.inject:jakarta.inject-api:2.0.1=posterImageMigrationTestRuntimeClasspath jakarta.persistence:jakarta.persistence-api:3.2.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath jakarta.transaction:jakarta.transaction-api:2.0.1=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath jakarta.validation:jakarta.validation-api:3.1.1=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +jakarta.xml.bind:jakarta.xml.bind-api:4.0.5=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath javax.inject:javax.inject:1=annotationProcessor,posterImageMigrationTestAnnotationProcessor,testAnnotationProcessor -jaxen:jaxen:2.0.0=spotbugs +jaxen:jaxen:2.0.6=spotbugs me.paulschwarz:spring-dotenv:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath net.bytebuddy:byte-buddy-agent:1.17.8=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath net.bytebuddy:byte-buddy:1.17.8=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath @@ -139,22 +138,22 @@ org.apache.commons:commons-text:1.15.0=spotbugs org.apache.commons:commons-text:1.3=checkstyle org.apache.httpcomponents:httpclient:4.5.13=checkstyle,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath org.apache.httpcomponents:httpcore:4.4.16=checkstyle,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.apache.logging.log4j:log4j-api:2.25.2=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath -org.apache.logging.log4j:log4j-core:2.25.2=spotbugs -org.apache.logging.log4j:log4j-to-slf4j:2.25.2=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-api:2.25.5=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath +org.apache.logging.log4j:log4j-core:2.25.5=spotbugs +org.apache.logging.log4j:log4j-to-slf4j:2.25.5=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.apache.maven.doxia:doxia-core:1.12.0=checkstyle org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle -org.apache.tomcat.embed:tomcat-embed-core:11.0.14=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.apache.tomcat.embed:tomcat-embed-el:11.0.14=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-core:11.0.24=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-el:11.0.24=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.apache.tomcat.embed:tomcat-embed-websocket:11.0.24=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.apache.xbean:xbean-reflect:3.7=checkstyle org.apiguardian:apiguardian-api:1.1.2=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.aspectj:aspectjweaver:1.9.25=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.assertj:assertj-core:3.27.6=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.aspectj:aspectjweaver:1.9.25.1=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.assertj:assertj-core:3.27.7=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.awaitility:awaitility:4.3.0=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.checkerframework:checker-qual:3.49.5=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +org.checkerframework:checker-qual:3.55.1=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle @@ -163,28 +162,28 @@ org.dom4j:dom4j:2.2.0=spotbugs org.eclipse.angus:angus-activation:2.0.3=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath org.flywaydb:flyway-core:11.14.1=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.flywaydb:flyway-database-postgresql:11.14.1=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.glassfish.jaxb:jaxb-core:4.0.6=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.glassfish.jaxb:jaxb-runtime:4.0.6=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.glassfish.jaxb:txw2:4.0.6=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +org.glassfish.jaxb:jaxb-core:4.0.9=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +org.glassfish.jaxb:jaxb-runtime:4.0.9=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +org.glassfish.jaxb:txw2:4.0.9=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath org.hamcrest:hamcrest:3.0=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.hdrhistogram:HdrHistogram:2.2.2=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath org.hibernate.models:hibernate-models:1.0.1=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.hibernate.orm:hibernate-core:7.1.8.Final=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.hibernate.orm:hibernate-core:7.2.24.Final=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.hibernate.validator:hibernate-validator:9.0.1.Final=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.javassist:javassist:3.28.0-GA=checkstyle -org.jboss.logging:jboss-logging:3.6.1.Final=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.jboss.logging:jboss-logging:3.6.3.Final=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath org.jetbrains.kotlin:kotlin-stdlib:2.2.21=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath org.jetbrains:annotations:13.0=productionRuntimeClasspath,runtimeClasspath org.jetbrains:annotations:17.0.0=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,posterImageMigrationTestAnnotationProcessor,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-api:6.0.1=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-engine:6.0.1=posterImageMigrationTestRuntimeClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-params:6.0.1=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter:6.0.1=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-commons:6.0.1=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-engine:6.0.1=posterImageMigrationTestRuntimeClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-launcher:6.0.1=posterImageMigrationTestRuntimeClasspath,testRuntimeClasspath -org.junit:junit-bom:6.0.1=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.jspecify:jspecify:1.0.1=annotationProcessor,checkstyle,compileClasspath,posterImageMigrationTestAnnotationProcessor,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-api:6.0.3=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.3=posterImageMigrationTestRuntimeClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.3=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.3=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.3=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.3=posterImageMigrationTestRuntimeClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.3=posterImageMigrationTestRuntimeClasspath,testRuntimeClasspath +org.junit:junit-bom:6.0.3=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.junit:junit-bom:6.1.0=spotbugs org.latencyutils:LatencyUtils:2.0.3=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath org.mockito:mockito-core:5.20.0=mockitoAgent,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath @@ -203,102 +202,103 @@ org.ow2.asm:asm-util:9.10.1=spotbugs org.ow2.asm:asm:9.10.1=spotbugs org.ow2.asm:asm:9.7.1=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.pcollections:pcollections:4.0.1=annotationProcessor,posterImageMigrationTestAnnotationProcessor,testAnnotationProcessor -org.postgresql:postgresql:42.7.8=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +org.postgresql:postgresql:42.7.13=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath org.reactivestreams:reactive-streams:1.0.4=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath org.reflections:reflections:0.10.2=checkstyle org.rnorth.duct-tape:duct-tape:1.0.8=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.skyscreamer:jsonassert:1.5.3=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.slf4j:slf4j-api:2.0.17=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath -org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j +org.slf4j:jul-to-slf4j:2.0.18=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-api:2.0.18=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath +org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.18=checkstyle org.springdoc:springdoc-openapi-starter-common:3.0.0=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath org.springdoc:springdoc-openapi-starter-webmvc-api:3.0.0=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-actuator-autoconfigure:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-actuator:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-configuration-processor:4.0.0=annotationProcessor -org.springframework.boot:spring-boot-data-commons:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-data-jpa-test:4.0.0=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-data-jpa:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-flyway:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-health:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-hibernate:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-http-client:4.0.0=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-http-converter:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-jackson:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-jdbc-test:4.0.0=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-jdbc:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-jpa-test:4.0.0=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-jpa:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-micrometer-metrics:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-micrometer-observation:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-persistence:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-restclient:4.0.0=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-resttestclient:4.0.0=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-security-oauth2-resource-server:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-security:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-servlet:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-sql:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-actuator:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-data-jpa:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-flyway:4.0.0=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-jackson:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-jdbc:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-micrometer-metrics:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-oauth2-resource-server:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-security:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-test:4.0.0=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-tomcat:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-validation:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-web:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter-webmvc:4.0.0=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-starter:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-test:4.0.0=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-testcontainers:4.0.0=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-tomcat:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-transaction:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-validation:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-web-server:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-webmvc-test:4.0.0=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot-webmvc:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.boot:spring-boot:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.data:spring-data-commons:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.data:spring-data-jpa:4.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.integration:spring-integration-core:7.0.0=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.springframework.integration:spring-integration-jdbc:7.0.0=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.springframework.security:spring-security-config:7.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.security:spring-security-core:7.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.security:spring-security-crypto:7.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.security:spring-security-oauth2-core:7.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.security:spring-security-oauth2-jose:7.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.security:spring-security-oauth2-resource-server:7.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.security:spring-security-test:7.0.0=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.security:spring-security-web:7.0.0=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework.session:spring-session-core:4.0.0=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.springframework:spring-aop:7.0.1=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-aspects:7.0.1=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-beans:7.0.1=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-context:7.0.1=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-core:7.0.1=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-expression:7.0.1=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-jdbc:7.0.1=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-messaging:7.0.1=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.springframework:spring-orm:7.0.1=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-test:7.0.1=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-tx:7.0.1=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-web:7.0.1=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.springframework:spring-webflux:7.0.1=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -org.springframework:spring-webmvc:7.0.1=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -org.testcontainers:testcontainers-database-commons:2.0.2=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.testcontainers:testcontainers-jdbc:2.0.2=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.testcontainers:testcontainers-junit-jupiter:2.0.2=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.testcontainers:testcontainers-postgresql:2.0.2=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.testcontainers:testcontainers:2.0.2=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-actuator-autoconfigure:4.0.8=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-actuator:4.0.8=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-autoconfigure:4.0.8=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-configuration-processor:4.0.8=annotationProcessor +org.springframework.boot:spring-boot-data-commons:4.0.8=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-data-jpa-test:4.0.8=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-data-jpa:4.0.8=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-flyway:4.0.8=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-health:4.0.8=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-hibernate:4.0.8=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-http-client:4.0.8=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-http-converter:4.0.8=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-jackson:4.0.8=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-jdbc-test:4.0.8=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-jdbc:4.0.8=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-jpa-test:4.0.8=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-jpa:4.0.8=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-micrometer-metrics:4.0.8=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-micrometer-observation:4.0.8=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-persistence:4.0.8=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-restclient:4.0.8=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-resttestclient:4.0.8=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-security-oauth2-resource-server:4.0.8=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-security:4.0.8=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-servlet:4.0.8=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-sql:4.0.8=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-actuator:4.0.8=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-data-jpa:4.0.8=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-flyway:4.0.8=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-jackson-test:4.0.8=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-jackson:4.0.8=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-jdbc:4.0.8=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-logging:4.0.8=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-micrometer-metrics:4.0.8=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-oauth2-resource-server:4.0.8=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-security:4.0.8=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-test:4.0.8=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.8=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-tomcat:4.0.8=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-validation:4.0.8=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-web:4.0.8=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-webmvc-test:4.0.8=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter-webmvc:4.0.8=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-starter:4.0.8=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-test-autoconfigure:4.0.8=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-test:4.0.8=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-testcontainers:4.0.8=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-tomcat:4.0.8=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-transaction:4.0.8=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-validation:4.0.8=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-web-server:4.0.8=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-webmvc-test:4.0.8=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot-webmvc:4.0.8=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.boot:spring-boot:4.0.8=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.data:spring-data-commons:4.0.7=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.data:spring-data-jpa:4.0.7=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.integration:spring-integration-core:7.0.6=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +org.springframework.integration:spring-integration-jdbc:7.0.6=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +org.springframework.security:spring-security-config:7.0.7=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.security:spring-security-core:7.0.7=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.security:spring-security-crypto:7.0.7=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.security:spring-security-oauth2-core:7.0.7=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.security:spring-security-oauth2-jose:7.0.7=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.security:spring-security-oauth2-resource-server:7.0.7=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.security:spring-security-test:7.0.7=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.security:spring-security-web:7.0.7=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework.session:spring-session-core:4.0.5=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +org.springframework:spring-aop:7.0.9=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-aspects:7.0.9=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-beans:7.0.9=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-context:7.0.9=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-core:7.0.9=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-expression:7.0.9=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-jdbc:7.0.9=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-messaging:7.0.9=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +org.springframework:spring-orm:7.0.9=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-test:7.0.9=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-tx:7.0.9=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-web:7.0.9=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.springframework:spring-webflux:7.0.9=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath +org.springframework:spring-webmvc:7.0.9=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +org.testcontainers:testcontainers-database-commons:2.0.5=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.testcontainers:testcontainers-jdbc:2.0.5=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.testcontainers:testcontainers-junit-jupiter:2.0.5=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.testcontainers:testcontainers-postgresql:2.0.5=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.testcontainers:testcontainers:2.0.5=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs org.xmlunit:xmlunit-core:2.10.4=posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.yaml:snakeyaml:2.5=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath @@ -332,7 +332,7 @@ software.amazon.awssdk:sdk-core:2.30.0=posterImageMigrationTestRuntimeClasspath, software.amazon.awssdk:third-party-jackson-core:2.30.0=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath software.amazon.awssdk:utils:2.30.0=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath software.amazon.eventstream:eventstream:1.0.1=posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testRuntimeClasspath -tools.jackson.core:jackson-core:3.0.2=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -tools.jackson.core:jackson-databind:3.0.2=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath -tools.jackson:jackson-bom:3.0.2=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +tools.jackson.core:jackson-core:3.1.5=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +tools.jackson.core:jackson-databind:3.1.5=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath +tools.jackson:jackson-bom:3.1.5=compileClasspath,posterImageMigrationTestCompileClasspath,posterImageMigrationTestRuntimeClasspath,productionRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath empty=developmentOnly,testAndDevelopmentOnly diff --git a/src/shared-contract/gradle.lockfile b/src/shared-contract/gradle.lockfile index 760135ca..52406714 100644 --- a/src/shared-contract/gradle.lockfile +++ b/src/shared-contract/gradle.lockfile @@ -1,7 +1,7 @@ # This is a Gradle generated file for dependency locking. # Manual edits can break the build and are not advised. # This file is expected to be part of source control. -com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,edgeRateLimitContractTestAnnotationProcessor,testAnnotationProcessor +com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,edgeRateLimitContractTestAnnotationProcessor,testAnnotationProcessor com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,edgeRateLimitContractTestAnnotationProcessor,testAnnotationProcessor com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs com.github.spotbugs:spotbugs:4.10.2=spotbugs @@ -33,7 +33,7 @@ info.picocli:picocli:4.7.7=checkstyle io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,edgeRateLimitContractTestAnnotationProcessor,testAnnotationProcessor io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,edgeRateLimitContractTestAnnotationProcessor,testAnnotationProcessor javax.inject:javax.inject:1=annotationProcessor,edgeRateLimitContractTestAnnotationProcessor,testAnnotationProcessor -jaxen:jaxen:2.0.0=spotbugs +jaxen:jaxen:2.0.6=spotbugs net.bytebuddy:byte-buddy:1.17.8=edgeRateLimitContractTestCompileClasspath,edgeRateLimitContractTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs org.antlr:antlr4-runtime:4.13.2=checkstyle @@ -43,30 +43,30 @@ org.apache.commons:commons-text:1.15.0=spotbugs org.apache.commons:commons-text:1.3=checkstyle org.apache.httpcomponents:httpclient:4.5.13=checkstyle org.apache.httpcomponents:httpcore:4.4.16=checkstyle -org.apache.logging.log4j:log4j-api:2.25.2=spotbugs -org.apache.logging.log4j:log4j-core:2.25.2=spotbugs +org.apache.logging.log4j:log4j-api:2.25.5=spotbugs +org.apache.logging.log4j:log4j-core:2.25.5=spotbugs org.apache.maven.doxia:doxia-core:1.12.0=checkstyle org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle org.apache.xbean:xbean-reflect:3.7=checkstyle org.apiguardian:apiguardian-api:1.1.2=edgeRateLimitContractTestCompileClasspath,testCompileClasspath -org.assertj:assertj-core:3.27.6=edgeRateLimitContractTestCompileClasspath,edgeRateLimitContractTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.assertj:assertj-core:3.27.7=edgeRateLimitContractTestCompileClasspath,edgeRateLimitContractTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle org.codehaus.plexus:plexus-utils:3.3.0=checkstyle org.dom4j:dom4j:2.2.0=spotbugs org.javassist:javassist:3.28.0-GA=checkstyle -org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,edgeRateLimitContractTestAnnotationProcessor,edgeRateLimitContractTestCompileClasspath,testAnnotationProcessor,testCompileClasspath -org.junit.jupiter:junit-jupiter-api:6.0.1=edgeRateLimitContractTestCompileClasspath,edgeRateLimitContractTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-engine:6.0.1=edgeRateLimitContractTestRuntimeClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter-params:6.0.1=edgeRateLimitContractTestCompileClasspath,edgeRateLimitContractTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.junit.jupiter:junit-jupiter:6.0.1=edgeRateLimitContractTestCompileClasspath,edgeRateLimitContractTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-commons:6.0.1=edgeRateLimitContractTestCompileClasspath,edgeRateLimitContractTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-engine:6.0.1=edgeRateLimitContractTestRuntimeClasspath,testRuntimeClasspath -org.junit.platform:junit-platform-launcher:6.0.1=edgeRateLimitContractTestRuntimeClasspath,testRuntimeClasspath -org.junit:junit-bom:6.0.1=edgeRateLimitContractTestCompileClasspath,edgeRateLimitContractTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.jspecify:jspecify:1.0.1=annotationProcessor,checkstyle,edgeRateLimitContractTestAnnotationProcessor,edgeRateLimitContractTestCompileClasspath,testAnnotationProcessor,testCompileClasspath +org.junit.jupiter:junit-jupiter-api:6.0.3=edgeRateLimitContractTestCompileClasspath,edgeRateLimitContractTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-engine:6.0.3=edgeRateLimitContractTestRuntimeClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter-params:6.0.3=edgeRateLimitContractTestCompileClasspath,edgeRateLimitContractTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.junit.jupiter:junit-jupiter:6.0.3=edgeRateLimitContractTestCompileClasspath,edgeRateLimitContractTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-commons:6.0.3=edgeRateLimitContractTestCompileClasspath,edgeRateLimitContractTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-engine:6.0.3=edgeRateLimitContractTestRuntimeClasspath,testRuntimeClasspath +org.junit.platform:junit-platform-launcher:6.0.3=edgeRateLimitContractTestRuntimeClasspath,testRuntimeClasspath +org.junit:junit-bom:6.0.3=edgeRateLimitContractTestCompileClasspath,edgeRateLimitContractTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath org.junit:junit-bom:6.1.0=spotbugs org.mockito:mockito-core:5.20.0=mockitoAgent org.opentest4j:opentest4j:1.3.0=edgeRateLimitContractTestCompileClasspath,edgeRateLimitContractTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath @@ -77,7 +77,8 @@ org.ow2.asm:asm-util:9.10.1=spotbugs org.ow2.asm:asm:9.10.1=spotbugs org.pcollections:pcollections:4.0.1=annotationProcessor,edgeRateLimitContractTestAnnotationProcessor,testAnnotationProcessor org.reflections:reflections:0.10.2=checkstyle -org.slf4j:slf4j-api:2.0.17=spotbugs,spotbugsSlf4j -org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j +org.slf4j:slf4j-api:2.0.18=spotbugs,spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j +org.slf4j:slf4j-simple:2.0.18=checkstyle org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs empty=compileClasspath,runtimeClasspath