feat: web, websocket 어댑터 추가 구현

This commit is contained in:
DongHyeonka
2026-08-28 17:01:27 +09:00
parent 0137263441
commit a24ece9cf7
883 changed files with 100584 additions and 2623 deletions
+8
View File
@@ -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'
@@ -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
@@ -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
+55
View File
@@ -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
+114
View File
@@ -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
+61
View File
@@ -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
@@ -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
+98
View File
@@ -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
+56
View File
@@ -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
+1 -1
View File
@@ -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
@@ -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 613 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.
@@ -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.
@@ -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.
@@ -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.
+60
View File
@@ -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 28 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.
@@ -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 913 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.
+11 -1
View File
@@ -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
+7 -1
View File
@@ -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
@@ -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
@@ -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 148
- `docs/superpowers/plans/2026-08-12-graphql-advanced-capabilities-expansion-plan.md`
- Stable Release Gate 이후 실행하는 Advanced·Experimental Task 119
- `VALIDATION.md`
- 정적 검증 결과와 검증 범위
- `validate_graphql_docs.py`
- 패키지 내부 문서 재검증 스크립트
- `MANIFEST.sha256`
- 패키지 파일 무결성 목록
## 구현 순서
```text
Stable Task 148
→ Stable Release Gate
→ Advanced Task 119
→ 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에 맞춰 경로만 조정하고, 문서의 공개 계약·불변 조건·테스트 의미는 유지한다.
@@ -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 15 | 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 148 / 119 연속성
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 148을 먼저 수행해 Stable Release Gate를 통과한 뒤 Advanced Task 119를 시작해야 합니다.
+249
View File
@@ -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 `148`",
]
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)
+1 -1
View File
@@ -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 |
+1 -1
View File
@@ -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. |
+1 -1
View File
@@ -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. |
@@ -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
+73
View File
@@ -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 통합 시험 결과를 포함하지 않습니다.
+193
View File
@@ -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
```
File diff suppressed because it is too large Load Diff
+286
View File
@@ -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)
+59
View File
@@ -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`.
+57
View File
@@ -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.
+78
View File
@@ -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.
+357
View File
@@ -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`.
+116
View File
@@ -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
```
+78
View File
@@ -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.
+78
View File
@@ -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.
@@ -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
@@ -0,0 +1,28 @@
# WebSocket Superpowers Package
이 패키지는 WebSocket 실시간 양방향 연결 실행 플랫폼의 설계서, Stable 구현 계획, Advanced 확장 계획, 요구사항 원본과 정적 검증 도구를 포함한다.
## 적용 순서
```text
Stable Task 153
→ Stable Release Gate
→ Advanced Task 122
→ 기능별 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 실행을 대체하지 않는다.
@@ -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 153 번호 연속성
- Advanced Task 122 번호 연속성
- 모든 Task의 `Files`, `Interfaces`, `Implementation requirements`, Step 15, 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
```
File diff suppressed because it is too large Load Diff
@@ -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)
+47
View File
@@ -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.
+180
View File
@@ -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 4748, 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/`.
+107
View File
@@ -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 3060s 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
```
+55
View File
@@ -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.
@@ -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.
+4
View File
@@ -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
+86 -85
View File
@@ -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=
@@ -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.
*
* <p>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.
*
* <p>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<String> allowedMetadataMimeTypes,
Set<String> 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");
}
}
}
}
@@ -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.
*
* <p>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.
*
* <p>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<String, Registration> registrations;
private GraphQlRepositoryAllowlist(Map<String, Registration> 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<Registration> find(GraphQlRepositoryExposure exposure) {
Objects.requireNonNull(exposure, "exposure");
return Optional.ofNullable(registrations.get(key(exposure)));
}
/** Every registered exposure, for the startup report. */
public List<String> 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<String, Registration> 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);
}
}
}
@@ -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.
*
* <p>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.
*
* <p>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<String> filterableFields;
private final Set<String> sortableFields;
/**
* @param filterableFields the arguments that may narrow the result
* @param sortableFields the fields that may order it
*/
public GraphQlRepositoryArgumentPolicy(Set<String> filterableFields, Set<String> 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.
*
* <p>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<String> reject(Set<String> filters, Set<String> 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();
}
}
@@ -0,0 +1,28 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.springdata;
import java.util.Objects;
/**
* One repository offered at one schema coordinate.
*
* <p>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);
}
}
}
@@ -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.
*
* <p>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;
}
}
@@ -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.
*
* <p>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.
*
* <p>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<String> filters,
Set<String> sorts,
int pageSize,
String returnTypeName) {
GraphQlRepositoryAllowlist.Registration registration = verify(exposure);
List<String> 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;
}
}
@@ -0,0 +1,35 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.springdata;
/**
* How many rows one coordinate may return, and how they are addressed.
*
* <p>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.
*
* <p>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;
}
}
@@ -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.
*
* <p>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.
*
* <p>So the entity type itself is refused by name, and only an approved projection is allowed.
*/
public final class GraphQlRepositoryProjectionPolicy {
private final Set<String> approvedProjections;
private final Set<String> forbiddenEntityTypes;
/**
* @param approvedProjections the projection interfaces or records that may be returned
* @param forbiddenEntityTypes the persistence types that may never be
*/
public GraphQlRepositoryProjectionPolicy(
Set<String> approvedProjections, Set<String> 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<String> 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<String> approvedProjections() {
return approvedProjections;
}
}
@@ -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.
*
* <p>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.
*
* <p>{@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.
*
* <p>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");
}
}
}
@@ -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.
*
* <p>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.
*
* <p>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");
}
}
}
}
@@ -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<String, TypeDefinition> previousTypes = withExtensions(previous);
Map<String, TypeDefinition> candidateTypes = withExtensions(candidate);
Map<String, TypeDefinition<?>> previousTypes = withExtensions(previous);
Map<String, TypeDefinition<?>> candidateTypes = withExtensions(candidate);
compareTypePresence(previousTypes, candidateTypes, changes);
compareTypeKinds(previousTypes, candidateTypes, changes);
@@ -89,14 +89,16 @@ public final class GraphQlSchemaComparator {
* <p>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<String, TypeDefinition> withExtensions(TypeDefinitionRegistry registry) {
Map<String, TypeDefinition> merged = new LinkedHashMap<>();
private static Map<String, TypeDefinition<?>> withExtensions(TypeDefinitionRegistry registry) {
Map<String, TypeDefinition<?>> 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 <T extends TypeDefinition> Map<String, T> typesOf(
Map<String, TypeDefinition> types, Class<T> kind) {
private static <T extends TypeDefinition<?>> Map<String, T> typesOf(
Map<String, TypeDefinition<?>> types, Class<T> kind) {
Map<String, T> selected = new LinkedHashMap<>();
types.forEach(
(name, type) -> {
@@ -200,8 +202,8 @@ public final class GraphQlSchemaComparator {
}
private static void compareTypePresence(
Map<String, TypeDefinition> previous,
Map<String, TypeDefinition> candidate,
Map<String, TypeDefinition<?>> previous,
Map<String, TypeDefinition<?>> candidate,
List<GraphQlSchemaChange> changes) {
Set<String> 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<String, TypeDefinition> previousTypes,
Map<String, TypeDefinition> candidateTypes,
Map<String, TypeDefinition<?>> previousTypes,
Map<String, TypeDefinition<?>> candidateTypes,
List<GraphQlSchemaChange> 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<String, TypeDefinition> previousTypes,
Map<String, TypeDefinition> candidateTypes,
Map<String, TypeDefinition<?>> previousTypes,
Map<String, TypeDefinition<?>> candidateTypes,
List<GraphQlSchemaChange> 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<String, TypeDefinition> previous,
Map<String, TypeDefinition> candidate,
Map<String, TypeDefinition<?>> previous,
Map<String, TypeDefinition<?>> candidate,
List<GraphQlSchemaChange> changes) {
Map<String, ImplementingTypeDefinition<?>> 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<GraphQlSchemaChange> changes) {
String coordinate, Type before, Type after, List<GraphQlSchemaChange> changes) {
if (sameType(before, after)) {
return;
@@ -474,8 +478,8 @@ public final class GraphQlSchemaComparator {
}
private static void compareInputTypes(
Map<String, TypeDefinition> previous,
Map<String, TypeDefinition> candidate,
Map<String, TypeDefinition<?>> previous,
Map<String, TypeDefinition<?>> candidate,
List<GraphQlSchemaChange> changes) {
Map<String, InputObjectTypeDefinition> 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<String, TypeDefinition> previous,
Map<String, TypeDefinition> candidate,
Map<String, TypeDefinition<?>> previous,
Map<String, TypeDefinition<?>> candidate,
List<GraphQlSchemaChange> changes) {
Map<String, EnumTypeDefinition> previousTypes = typesOf(previous, EnumTypeDefinition.class);
@@ -586,8 +592,8 @@ public final class GraphQlSchemaComparator {
}
private static void compareUnions(
Map<String, TypeDefinition> previous,
Map<String, TypeDefinition> candidate,
Map<String, TypeDefinition<?>> previous,
Map<String, TypeDefinition<?>> candidate,
List<GraphQlSchemaChange> changes) {
Map<String, UnionTypeDefinition> previousTypes = typesOf(previous, UnionTypeDefinition.class);
@@ -677,7 +683,7 @@ public final class GraphQlSchemaComparator {
}
private static Map<String, ImplementingTypeDefinition<?>> implementingTypes(
Map<String, TypeDefinition> merged) {
Map<String, TypeDefinition<?>> merged) {
Map<String, ImplementingTypeDefinition<?>> 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.
*
* <p>Every {@code TypeDefinition} in this class is parameterized. {@code graphql.language.Type}
* is not, and cannot be: graphql-java declares {@code Type<T extends Type>} its own bound is
* raw and its collections are raw on both sides. {@code getImplements()} and {@code
* getMemberTypes()} return {@code List<Type>}, which is not assignable to {@code List<? extends
* Type<?>>} because a raw element type is not a subtype of {@code Type<?>} in an argument
* position; and {@code UnionTypeDefinition.Builder.memberTypes} takes {@code List<Type>} back, so
* a parameterized local cannot be handed to it either. Reading and writing are both raw.
*
* <p>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.
*
* <p>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<String> typeNames(List<Type> 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<Class<? extends TypeDefinition>> comparedDefinitionKinds() {
public static Set<Class<? extends TypeDefinition<?>>> comparedDefinitionKinds() {
return Set.of(
ObjectTypeDefinition.class,
InterfaceTypeDefinition.class,
@@ -88,6 +88,16 @@ public enum GraphQlAdvancedModule {
"security"),
/** The GraphQL over WebSocket protocol state machine. */
/**
* The allowlisted Spring Data compatibility path.
*
* <p>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;
@@ -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.
*
* <p>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();
}
}
@@ -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.
*
* <p>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);
}
}
+67 -69
View File
@@ -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=
+193
View File
@@ -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.'
+237 -148
View File
@@ -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=
@@ -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.
*
* <p>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);
}
}
@@ -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";
}
}
@@ -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.
*
* <p>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);
}
}
@@ -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.
*
* <p>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.
*
* <p>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();
}
}
@@ -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.
*
* <p>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);
}
}
@@ -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.
*
* <p>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);
}
}
@@ -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.
*
* <p>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();
}
}
@@ -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.
*
* <p>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;
}
}
}
@@ -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
@@ -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.
*
* <p>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<Integer> apiVersions,
List<String> problemCodes,
Map<String, String> budgetProfiles,
Map<String, String> admissionProfiles,
List<String> cacheProfiles,
Map<String, Boolean> 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<String> uninstalledControls() {
return installedControls.entrySet().stream()
.filter(entry -> !entry.getValue())
.map(Map.Entry::getKey)
.sorted()
.toList();
}
}
@@ -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.
*
* <p>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.
*
* <p>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<String> required;
/**
* A validator over the controls this deployment claims.
*
* @param required the controls that must be wired
*/
public WebPlatformStartupValidator(List<String> 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<String> 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");
}
}
}
@@ -0,0 +1,18 @@
package dev.caskeleton.adapter.inbound.web.admin.route;
/**
* The routes this deployment actually serves are not the routes it declared.
*
* <p>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);
}
}
@@ -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.
*
* <p>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.
*
* <p>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<String> patterns = patternsOf(info);
Set<org.springframework.web.bind.annotation.RequestMethod> 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.
*
* <p>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<String> patternsOf(RequestMappingInfo info) {
if (info.getPathPatternsCondition() != null) {
return info.getPathPatternsCondition().getPatternValues();
}
return Set.of();
}
private static List<String> mediaTypes(Set<org.springframework.http.MediaType> 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);
}
}
@@ -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.
*
* <p>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<String> consumes,
List<String> produces,
boolean deprecated,
Optional<Instant> 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.
*
* <p>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;
}
}
@@ -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.
*
* <p>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.
*
* <p>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<String, WebRouteContract> 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<String> 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<String> approvedKeys) {
Objects.requireNonNull(approvedKeys, "approvedKeys");
List<String> added = new ArrayList<>(routes.keySet());
added.removeAll(approvedKeys);
List<String> 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<String, WebRouteContract> routes() {
return Map.copyOf(new LinkedHashMap<>(routes));
}
/** The uniqueness keys, for writing or comparing a manifest. */
public java.util.Set<String> keys() {
return java.util.Set.copyOf(routes.keySet());
}
/** How many routes are served. */
public int size() {
return routes.size();
}
}
@@ -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.
*
* <p>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<AdmissionPermit> permit,
Optional<Duration> 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);
}
}
@@ -0,0 +1,21 @@
package dev.caskeleton.adapter.inbound.web.admission;
/**
* The right to occupy one execution slot, given up when closed.
*
* <p>{@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.
*
* <p>{@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();
}
@@ -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.
*
* <p>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.
*
* <p>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.
*
* <p>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));
}
}
@@ -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.
*
* <p>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.
*
* <p>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<AdmissionProfileName, Gate> 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();
}
}
@@ -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.
*
* <p>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);
}
@@ -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.
*
* <p>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.
*
* <p>Every constant is off unless named.
*/
public enum WebAdvancedFeature {
/**
* A virtual-thread executor for MVC request handling.
*
* <p>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.
*
* <p>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;
}
}
@@ -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.
*
* <p>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<WebAdvancedFeature> enabled;
private WebAdvancedFeatureFlags(Set<WebAdvancedFeature> 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<WebAdvancedFeature> 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);
}
}
@@ -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.
*
* <p>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 = "<unregistered>";
private final BlockingBridgeProfile profile;
private final Semaphore permits;
private final AtomicInteger inFlight = new AtomicInteger();
private final AtomicInteger peakConcurrency = new AtomicInteger();
private final Map<String, LongAdder> 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();
}
}
@@ -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.
*
* <p>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.
*
* <p>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<String> 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);
}
}
@@ -0,0 +1,47 @@
package dev.caskeleton.adapter.inbound.web.advanced.blockingbridge;
import java.util.Objects;
/**
* A blocking offload was refused.
*
* <p>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
}
}

Some files were not shown because too many files have changed in this diff Show More