feat: web, websocket 어댑터 추가 구현
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,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 6–13 add SSE, NDJSON and JSON text sequences, with a `Last-Event-ID` resume path.
|
||||
|
||||
One fact drives every decision here: **after the first byte, the HTTP status is 200 and cannot
|
||||
change.** A stream that ends because a dependency failed and one that ends because it finished are
|
||||
identical at the transport layer — both are a closed connection after a 200. So is a stream that was
|
||||
cut off mid-flight.
|
||||
|
||||
The second fact is that a resume path invites the web module to remember things. It must not: the
|
||||
messaging platform already owns durable event history, and a second copy would have its own
|
||||
retention, its own eviction and its own opinion about ordering.
|
||||
|
||||
## Decision
|
||||
|
||||
**Three outcomes, expressed in the stream rather than in the status.** `WebStreamEnvelope` is sealed
|
||||
over `Item`, `Failure` and `Complete`. A client that sees neither terminal envelope has been cut off,
|
||||
and that third case is recorded as `ABRUPT_CLOSE` rather than counted as a completion — which is
|
||||
where a rising rate of mid-stream failures would otherwise hide.
|
||||
|
||||
**Nothing writes a problem document onto a committed response.** `WebStreamTerminationMapper`
|
||||
branches on whether any byte has been written. Before commit, an RFC 9457 problem with a real
|
||||
status; after, a terminal record. Attempting both produces a body that is half stream and half JSON,
|
||||
which no client parses and every proxy caches as a success.
|
||||
|
||||
**Positions are monotonic, and it is enforced.** `WebStreamEvidence.recordDelivered` refuses a
|
||||
repeated or regressing position. A client deduplicating on position would silently drop the second
|
||||
item.
|
||||
|
||||
**A slow consumer is disconnected, not buffered.** `WebStreamPolicy.maxBufferedItems` is a hard
|
||||
bound. Backpressure protects the reactive pipeline; it does not protect the server's heap from a
|
||||
consumer that reads slowly for an hour.
|
||||
|
||||
**Every stream is in a registry, and shutdown drains it.** A node with a hundred open streams and no
|
||||
other traffic looks idle by request rate. `WebStreamDrainCoordinator` stops accepting first, asks
|
||||
clients to reconnect, and only then forces the remainder — because a client whose socket is cut
|
||||
retries immediately, and if every socket is cut at once, every client retries at once.
|
||||
|
||||
**The web module stores no durable history.** `WebStreamReplaySource` is an interface this module
|
||||
implements nowhere. An expired cursor raises `ReplayCursorExpiredException` rather than resuming from
|
||||
the oldest retained position, because that delivers a stream with a hole the client cannot see.
|
||||
|
||||
**The replay-to-live seam is watched.** `GapAndDuplicateGuard` detects both directions. Neither is
|
||||
visible in either half on its own.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Clients must handle three outcomes. A client that treats a closed connection as completion will be
|
||||
wrong, and no server change can fix that for it.
|
||||
- An expired `Last-Event-ID` costs the client a full re-read. That is the honest answer.
|
||||
- JSON-seq is preferred over NDJSON where truncation matters: its separator comes first, so a parser
|
||||
resynchronises at the next record. NDJSON's delimiter is the thing that gets truncated away.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Emit a problem document when a stream fails after commit.** Rejected: the body becomes
|
||||
unparseable and the 200 is cached.
|
||||
- **Resume from the oldest retained position when the cursor expires.** Rejected: positions are
|
||||
contiguous from where the replay started, so nothing in the data says events are missing.
|
||||
- **Store replay history in the web module.** Rejected: a second source of truth that drifts
|
||||
invisibly.
|
||||
- **Unbounded buffering for slow consumers.** Rejected: it moves the client's slowness into the
|
||||
server's heap.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,60 @@
|
||||
# ADR-WS-002: Resume and cluster state are caches, and are treated as caches
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-08-25
|
||||
- Scope: `adapter:inbound:websocket` — `advanced.resume`, `advanced.cluster`, `advanced.presence`
|
||||
|
||||
## Context
|
||||
|
||||
Advanced Tasks 2–8 add three things that all look like state and are not: a resume token that says
|
||||
where a client got to, a cluster index that says which node holds a session, and a presence summary
|
||||
derived from that index.
|
||||
|
||||
Each is a statement about the past. The resume token was minted before the disconnect; the index
|
||||
entry was written by a node that may since have died; presence is a read of the index and inherits
|
||||
everything wrong with it. The failure this ADR exists to prevent is treating any of them as current
|
||||
fact, because each reads as one at the call site.
|
||||
|
||||
## Decision
|
||||
|
||||
**Resume is bounded by what the replay store actually holds, not by what the token claims.**
|
||||
`ResumeCoordinator` consults `ReplayAvailability` before honouring a position. A token that names a
|
||||
position the store has evicted produces a resynchronise, not a gap-filled stream. The alternative —
|
||||
trusting the token — silently delivers a stream with a hole in it, which is worse than an explicit
|
||||
resynchronise because the client believes it is complete.
|
||||
|
||||
**Cluster index entries carry an observation time and are checked against it on every read.**
|
||||
`ExternalSessionSummary.staleAt` exists so that "the index says edge-2" cannot be used without also
|
||||
answering "as of when". An entry whose node stopped reporting is not evidence that the node holds
|
||||
the session.
|
||||
|
||||
**Durable fan-out is deduplicated by stream position, not by message id.** At-least-once is the
|
||||
contract, so redelivery is normal operation: a redeploy, a slow consumer or a broker rebalance all
|
||||
produce it. `FanoutDeduplicator` keys on `(stream, position)` and advances a high-water mark under
|
||||
`compute`, so two consumer threads cannot both deliver the same position.
|
||||
|
||||
**Presence has four states, not two.** `OFFLINE` is a reported fact; `STALE` is the absence of one.
|
||||
Collapsing them reports every user as disconnected during a Redis partition, when what happened is
|
||||
that the index went dark and the connections are fine.
|
||||
|
||||
**Nothing security-relevant may depend on presence.** An attacker who can make a node stop reporting
|
||||
can move the platform's belief about who is present. Presence answers "show a green dot".
|
||||
|
||||
## Consequences
|
||||
|
||||
- A resume that cannot be honoured is visible to the client as a resynchronise. Clients must
|
||||
implement one; there is no mode in which the platform silently pretends.
|
||||
- Every read of the cluster index needs a clock. This is deliberate friction.
|
||||
- `PresenceSummary.classify` refuses an idle window at or past the stale window, because otherwise
|
||||
`IDLE` is unreachable and the caller believes it has a four-state model when it has three.
|
||||
- Fan-out envelopes carry a bounded reference and the catalog-encoded document, never a business
|
||||
object. A rolling deploy has two versions of the code reading the same envelope.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Trust the resume token.** Rejected: it makes a gap indistinguishable from a complete stream.
|
||||
- **Deduplicate by message id.** Rejected: a broker that redelivers may re-mint ids, and a producer
|
||||
that retries certainly does. Position is the property the ordering actually has.
|
||||
- **A single `online` boolean.** Rejected for the partition case above.
|
||||
- **Write presence separately from the session index.** Rejected: two sources of truth for "who is
|
||||
connected" drift, and the drift is invisible — both look plausible and nothing reconciles them.
|
||||
@@ -0,0 +1,74 @@
|
||||
# ADR-WS-003: STOMP is an Advanced adapter with a declared destination catalog
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-08-25
|
||||
- Scope: `adapter:inbound:websocket` — `advanced.stomp`, `advanced.stomp.rabbit`
|
||||
|
||||
## Context
|
||||
|
||||
Advanced Tasks 9–13 add STOMP 1.2 alongside the platform's own protocol, plus a RabbitMQ broker
|
||||
relay and cross-node user destinations.
|
||||
|
||||
This leaf already ships an older STOMP-over-SockJS channel (`stomp`, gated on
|
||||
`ca-skeleton.websocket.enabled`). Two `@EnableWebSocketMessageBroker` configurations in one context
|
||||
do not conflict loudly — both contribute a configurer, both call `configureMessageBroker`, and the
|
||||
broker that results is whichever ran last. Nothing errors and nothing logs.
|
||||
|
||||
STOMP also brings a destination model that is a free string from the client. Without a catalog, the
|
||||
set of reachable destinations is whatever the broker accepts, which for the simple broker is every
|
||||
string.
|
||||
|
||||
## Decision
|
||||
|
||||
**The Advanced adapter is its own module (`advanced-stomp`), separate from `advanced`.** It is the
|
||||
one Advanced capability that cannot be pure — STOMP here *is* the Spring Messaging types — and
|
||||
folding it into `advanced` would relax that module's purity for every capability in it.
|
||||
|
||||
**The relay is a further module (`advanced-stomp-rabbit`).** The adapter parses a protocol; the
|
||||
relay opens a TCP connection to somebody else's broker and makes every delivery depend on it.
|
||||
Different blast radius, different decision, different module.
|
||||
|
||||
**Destinations are declared, per operation.** `StompDestinationCatalog` maps `(operation,
|
||||
destination)` to a required permission. Undeclared is refused. `SUBSCRIBE` and `SEND` are separate
|
||||
declarations, because reading a feed and publishing into it are different rights.
|
||||
|
||||
**The authorization decision is a value, not an interceptor method.** `StompAuthorizationPolicy`
|
||||
returns a `StompAuthorizationDecision`; `StompSecurityInterceptor` only extracts and enforces. A rule
|
||||
reachable only through a `MessageChannel` gets tested for the cases somebody built a channel for.
|
||||
|
||||
**Only one STOMP runtime may run.** `StompBrokerExclusivity` fails the context when both channels
|
||||
are enabled, when both brokers are, or when the adapter is enabled with no broker behind it.
|
||||
|
||||
**A `RECEIPT` is never promoted to a commit.** `StompEvidence` has six stages and
|
||||
`StompAckPolicy.evidenceForReceipt()` is fixed at `PROTOCOL_RECEIPT`. The receipt is written by the
|
||||
protocol layer, which knows nothing about whether the work succeeded.
|
||||
|
||||
**The simple broker declares what it cannot do.** `SimpleBrokerProfile` cannot be constructed
|
||||
claiming cluster support or durable acks, and refuses activation outside local/test — in a
|
||||
multi-node deployment it does not error, it delivers to whichever fraction of users is on the
|
||||
publishing node.
|
||||
|
||||
**Unresolved user destinations are broadcast once and then dead-lettered.**
|
||||
`MultiNodeUserDestination` distinguishes a message that arrived *via* the broadcast from one that did
|
||||
not. Without that, every node rebroadcasts every unresolvable message on receipt.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Enabling Advanced STOMP requires disabling the legacy channel. There is no migration path that
|
||||
runs both; the exclusivity check makes that explicit at startup rather than at 3am.
|
||||
- A deployment must write its own catalog. There is deliberately no default: an empty one refuses
|
||||
every frame and reads as a broken adapter, and a non-empty one publishes destinations nobody chose.
|
||||
- The relay's cost is one broker connection per authenticated session plus one system connection.
|
||||
`brokerConnectionsFor` exists so this is computed before the first outage.
|
||||
- User-destination metrics are tagged with `UserDestinationAction`, never the destination — a user
|
||||
destination contains a user identifier by construction.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Extend the existing `stomp` package.** Rejected: it is Stable, and WS-ARCH-6 forbids a Stable
|
||||
module naming an Advanced one. Making the legacy channel profile-driven would have required that
|
||||
edge.
|
||||
- **One `advanced-stomp` module including the relay.** Rejected: the relay is a separate operational
|
||||
decision and deserves to be refusable on its own.
|
||||
- **Allow undeclared destinations with a wildcard permission.** Rejected: the wildcard becomes the
|
||||
default and the catalog becomes documentation.
|
||||
@@ -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
|
||||
|
||||
@@ -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 1–48
|
||||
- `docs/superpowers/plans/2026-08-12-graphql-advanced-capabilities-expansion-plan.md`
|
||||
- Stable Release Gate 이후 실행하는 Advanced·Experimental Task 1–19
|
||||
- `VALIDATION.md`
|
||||
- 정적 검증 결과와 검증 범위
|
||||
- `validate_graphql_docs.py`
|
||||
- 패키지 내부 문서 재검증 스크립트
|
||||
- `MANIFEST.sha256`
|
||||
- 패키지 파일 무결성 목록
|
||||
|
||||
## 구현 순서
|
||||
|
||||
```text
|
||||
Stable Task 1–48
|
||||
→ Stable Release Gate
|
||||
→ Advanced Task 1–19
|
||||
→ Capability별 Promotion Gate
|
||||
```
|
||||
|
||||
## 명시적 전제
|
||||
|
||||
```text
|
||||
Java 21
|
||||
Gradle Kotlin DSL
|
||||
Spring Boot 4.1 BOM
|
||||
Spring for GraphQL 2.0
|
||||
Boot-managed GraphQL Java v25 계열
|
||||
Stable module root: modules/graphql
|
||||
Advanced module root: modules/graphql-advanced
|
||||
Root package: io.backend.skeleton.graphql
|
||||
```
|
||||
|
||||
실제 저장소에 적용할 때 기존 package·version catalog·module naming에 맞춰 경로만 조정하고, 문서의 공개 계약·불변 조건·테스트 의미는 유지한다.
|
||||
@@ -0,0 +1,98 @@
|
||||
# GraphQL Superpowers 문서 정적 검증 결과
|
||||
|
||||
- **검증 시각 기준:** 2026-08-12
|
||||
- **검증 대상:** 설계서 1개, Stable 구현 계획서 1개, Advanced·Experimental 확장 계획서 1개
|
||||
- **검증 명령:** `python3 validate_graphql_docs.py`
|
||||
- **결과:** **PASS**
|
||||
- **실행 검사:** 1,475
|
||||
- **통과:** 1,475
|
||||
- **실패:** 0
|
||||
|
||||
## 문서 규모
|
||||
|
||||
| 문서 | 행 수 | 크기 |
|
||||
|---|---:|---:|
|
||||
| GraphQL API 실행 플랫폼 설계서 | 2,553 | 93,359 bytes |
|
||||
| Stable 구현 계획서 | 4,560 | 209,041 bytes |
|
||||
| Advanced 확장 계획서 | 1,976 | 105,717 bytes |
|
||||
|
||||
## 계획 구조
|
||||
|
||||
| 항목 | Stable | Advanced |
|
||||
|---|---:|---:|
|
||||
| Task 수 | 48 | 19 |
|
||||
| Create 경로 수 | 227 | 113 |
|
||||
| Task 번호 연속성 | PASS | PASS |
|
||||
| 모든 Task의 `Files`·`Interfaces` | PASS | PASS |
|
||||
| 모든 Task의 Implementation Requirements | PASS | PASS |
|
||||
| 모든 Task의 Step 1–5 | PASS | PASS |
|
||||
| 실패·통과 예상 결과 | PASS | PASS |
|
||||
| Task별 Git commit 명령 | PASS | PASS |
|
||||
| Create 경로 중복 | 없음 | 없음 |
|
||||
| Stable·Advanced 경로 충돌 | 없음 | 없음 |
|
||||
|
||||
## 핵심 계약 검증
|
||||
|
||||
```text
|
||||
SDL-first external contract
|
||||
Single Executable Schema Stable default
|
||||
HTTP POST Stable profile
|
||||
application/graphql-response+json preferred
|
||||
Validation 이후 Field Error·Partial Data는 HTTP 200
|
||||
Draft 294는 Stable에서 제외
|
||||
JPA Entity·MongoDB Document 직접 노출 금지
|
||||
GraphQL Multipart Upload 미지원·Fileserver 사용
|
||||
request-wide database transaction 금지
|
||||
DataLoader request scope
|
||||
Finite Fetch Profile
|
||||
HMAC-signed cursor
|
||||
Mutation idempotency·expected version 분리
|
||||
Parser·shape·complexity·runtime response budget
|
||||
Actor·Field·Object·Tenant authorization
|
||||
Low-cardinality observability
|
||||
Stable/Advanced dependency isolation
|
||||
Persisted Operation·WebSocket·SSE·Federation 분리
|
||||
RSocket·HTTP GET·Incremental Delivery Experimental
|
||||
```
|
||||
|
||||
위 계약은 설계서와 계획서의 필수 문자열·모듈 경로·Task별 파일·테스트를 대조해 검증했습니다.
|
||||
|
||||
## 입력 리서치 추적성
|
||||
|
||||
- 첨부된 `GraphQL API 실행 플랫폼 심층 리서치` 원문 전체가 설계서의 `부록 B`에 포함되어 있습니다.
|
||||
- 설계 본문은 원문의 용어와 결론을 유지하면서 구현 판단을 Stable·Advanced·Experimental로 고정합니다.
|
||||
- 설계서와 입력 원문의 exact text 포함 검사를 별도로 통과했습니다.
|
||||
|
||||
## 패키지 검증 항목
|
||||
|
||||
```text
|
||||
문서 파일 존재
|
||||
Markdown code fence 균형
|
||||
Task 1–48 / 1–19 연속성
|
||||
Task별 테스트·명령·commit
|
||||
정확한 Create 경로
|
||||
Placeholder 금지
|
||||
Stable module에 WebSocket·Federation·Persisted Operation 경로 부재
|
||||
Advanced module에 feature flag와 capability 경로 존재
|
||||
금지 API pattern 부재
|
||||
문서 SHA-256 계산
|
||||
```
|
||||
|
||||
## 검증 범위의 한계
|
||||
|
||||
현재 PASS는 **문서의 정적 구조, 요구사항 추적성, 내부 계약과 실행 계획의 완결성**을 의미합니다. 실제 Backend Skeleton 저장소가 입력으로 제공되지 않았으므로 다음은 실행하지 않았습니다.
|
||||
|
||||
```text
|
||||
Gradle configuration·compile
|
||||
Spring Boot ApplicationContext 기동
|
||||
SchemaMappingInspector 실제 결과
|
||||
GraphQlTester HTTP·WebFlux contract
|
||||
JPA·MongoDB statement/query-count integration
|
||||
query bomb·complexity load test
|
||||
Virtual Thread·event-loop blocking test
|
||||
WebSocket·SSE soak test
|
||||
Federation composition·router integration
|
||||
actual Git commit
|
||||
```
|
||||
|
||||
실제 구현에서는 Stable Task 1–48을 먼저 수행해 Stable Release Gate를 통과한 뒤 Advanced Task 1–19를 시작해야 합니다.
|
||||
+1976
File diff suppressed because it is too large
Load Diff
+4560
File diff suppressed because it is too large
Load Diff
+2553
File diff suppressed because it is too large
Load Diff
+249
@@ -0,0 +1,249 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
import hashlib
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
DESIGN = ROOT / "docs/superpowers/specs/2026-08-12-graphql-api-execution-platform-design.md"
|
||||
STABLE = ROOT / "docs/superpowers/plans/2026-08-12-graphql-api-execution-platform-implementation-plan.md"
|
||||
ADVANCED = ROOT / "docs/superpowers/plans/2026-08-12-graphql-advanced-capabilities-expansion-plan.md"
|
||||
|
||||
checks: list[tuple[str, bool, str]] = []
|
||||
|
||||
def check(name: str, condition: bool, detail: str = "") -> None:
|
||||
checks.append((name, bool(condition), detail))
|
||||
|
||||
def read(path: Path) -> str:
|
||||
check(f"file exists: {path.name}", path.exists(), str(path))
|
||||
return path.read_text(encoding="utf-8") if path.exists() else ""
|
||||
|
||||
design = read(DESIGN)
|
||||
stable = read(STABLE)
|
||||
advanced = read(ADVANCED)
|
||||
|
||||
# Basic document integrity
|
||||
check("design line floor", len(design.splitlines()) >= 2000, str(len(design.splitlines())))
|
||||
check("stable plan line floor", len(stable.splitlines()) >= 4000, str(len(stable.splitlines())))
|
||||
check("advanced plan line floor", len(advanced.splitlines()) >= 1500, str(len(advanced.splitlines())))
|
||||
for label, text in [("design", design), ("stable", stable), ("advanced", advanced)]:
|
||||
check(f"{label} code fences balanced", text.count("```") % 2 == 0, str(text.count("```")))
|
||||
for marker in ["TODO", "TBD", "FIXME", "implement later", "fill in details"]:
|
||||
check(f"{label} no placeholder {marker}", marker.lower() not in text.lower())
|
||||
|
||||
# Design required sections and source traceability
|
||||
required_design_terms = [
|
||||
"# GraphQL API 실행 플랫폼 설계서",
|
||||
"GraphQL Platform owns",
|
||||
"Domain/Application owns",
|
||||
"G1 Standard GraphQL API",
|
||||
"G2 Advanced Execution",
|
||||
"G3 GraphQL Extension",
|
||||
"G4 Admin Plane",
|
||||
"SDL",
|
||||
"September 2025",
|
||||
"application/graphql-response+json",
|
||||
"HTTP `200`",
|
||||
"GraphQlRequestContext",
|
||||
"DataLoader",
|
||||
"GraphQlFetchProfile",
|
||||
"HMAC",
|
||||
"Idempotency",
|
||||
"Partial Data",
|
||||
"Persisted Operation",
|
||||
"Subscription",
|
||||
"Federation",
|
||||
"GraphQL Multipart Upload",
|
||||
"Fileserver",
|
||||
"부록 B. 입력 심층 리서치 원문",
|
||||
"# GraphQL API 실행 플랫폼 심층 리서치",
|
||||
]
|
||||
for term in required_design_terms:
|
||||
check(f"design contains {term}", term in design)
|
||||
|
||||
# Critical design invariants
|
||||
critical_pairs = [
|
||||
("field error uses HTTP 200", "field error" in design.lower() and "HTTP `200`" in design),
|
||||
("no draft 294 stable", "294" in design and "Stable" in design),
|
||||
("dataloader request scope", "request" in design.lower() and "DataLoader" in design),
|
||||
("cursor HMAC", "Cursor" in design and "HMAC" in design),
|
||||
("no multipart upload", "Multipart Upload" in design and "Fileserver" in design),
|
||||
("single schema default", "Single Executable Schema" in design),
|
||||
("request-wide transaction prohibited", "request-wide" in design.lower() and "transaction" in design.lower()),
|
||||
("entity/document boundary", "JPA Entity" in design and "MongoDB Document" in design),
|
||||
]
|
||||
for name, condition in critical_pairs:
|
||||
check(name, condition)
|
||||
|
||||
# Plan headers and global constraints
|
||||
stable_header_terms = [
|
||||
"# GraphQL API 실행 플랫폼 Implementation Plan",
|
||||
"REQUIRED SUB-SKILL",
|
||||
"**Goal:**",
|
||||
"**Architecture:**",
|
||||
"**Tech Stack:**",
|
||||
"## Global Constraints",
|
||||
"Stable Task",
|
||||
]
|
||||
advanced_header_terms = [
|
||||
"# GraphQL Advanced Capability Expansion Implementation Plan",
|
||||
"REQUIRED SUB-SKILL",
|
||||
"backend.graphql.advanced.*",
|
||||
"Stable 구현 계획 Task `1–48`",
|
||||
]
|
||||
for term in stable_header_terms:
|
||||
check(f"stable header contains {term}", term in stable)
|
||||
for term in advanced_header_terms:
|
||||
check(f"advanced header contains {term}", term in advanced)
|
||||
|
||||
# Task sequence and per-task structure
|
||||
def task_sections(text: str) -> list[tuple[int, str]]:
|
||||
matches = list(re.finditer(r"^### Task (\d+): .+$", text, re.MULTILINE))
|
||||
result = []
|
||||
for i, match in enumerate(matches):
|
||||
start = match.start()
|
||||
end = matches[i+1].start() if i+1 < len(matches) else len(text)
|
||||
result.append((int(match.group(1)), text[start:end]))
|
||||
return result
|
||||
|
||||
stable_tasks = task_sections(stable)
|
||||
advanced_tasks = task_sections(advanced)
|
||||
check("stable task count", len(stable_tasks) == 48, str(len(stable_tasks)))
|
||||
check("advanced task count", len(advanced_tasks) == 19, str(len(advanced_tasks)))
|
||||
check("stable task sequence", [n for n, _ in stable_tasks] == list(range(1, 49)))
|
||||
check("advanced task sequence", [n for n, _ in advanced_tasks] == list(range(1, 20)))
|
||||
|
||||
def validate_tasks(label: str, tasks: list[tuple[int, str]]) -> None:
|
||||
required = [
|
||||
"**Files:**",
|
||||
"**Interfaces:**",
|
||||
"**Implementation requirements:**",
|
||||
"**Step 1: Write the failing test**",
|
||||
"**Step 2: Run the focused test and verify the failure**",
|
||||
"**Step 3: Implement the smallest complete production contract**",
|
||||
"**Step 4: Run the focused test and the owning suite**",
|
||||
"**Step 5: Commit the independently reviewable change**",
|
||||
"Expected: FAIL",
|
||||
"Expected: PASS",
|
||||
"git commit -m",
|
||||
]
|
||||
for number, section in tasks:
|
||||
for token in required:
|
||||
check(f"{label} task {number} contains {token}", token in section)
|
||||
check(f"{label} task {number} has test path", "- Test: `" in section)
|
||||
check(f"{label} task {number} has production file", "- Create: `" in section)
|
||||
check(f"{label} task {number} fences balanced", section.count("```") % 2 == 0)
|
||||
check(f"{label} task {number} has gradle test", "./gradlew" in section and ":test" in section)
|
||||
|
||||
validate_tasks("stable", stable_tasks)
|
||||
validate_tasks("advanced", advanced_tasks)
|
||||
|
||||
# Create paths
|
||||
def create_paths(text: str) -> list[str]:
|
||||
return re.findall(r"^- Create: `([^`]+)`$", text, re.MULTILINE)
|
||||
|
||||
stable_paths = create_paths(stable)
|
||||
advanced_paths = create_paths(advanced)
|
||||
check("stable create paths exist", len(stable_paths) >= 150, str(len(stable_paths)))
|
||||
check("advanced create paths exist", len(advanced_paths) >= 80, str(len(advanced_paths)))
|
||||
check("stable create paths unique", len(stable_paths) == len(set(stable_paths)))
|
||||
check("advanced create paths unique", len(advanced_paths) == len(set(advanced_paths)))
|
||||
check("stable and advanced paths disjoint", set(stable_paths).isdisjoint(advanced_paths))
|
||||
for index, path in enumerate(stable_paths, 1):
|
||||
check(f"stable create path {index} exact", "*" not in path and "..." not in path and (path.startswith("modules/graphql/") or path.startswith("build-logic/")))
|
||||
for index, path in enumerate(advanced_paths, 1):
|
||||
check(f"advanced create path {index} exact", "*" not in path and "..." not in path and path.startswith("modules/graphql-advanced/"))
|
||||
|
||||
# Stable/Advanced separation
|
||||
for forbidden in [
|
||||
"modules/graphql/graphql-websocket/",
|
||||
"modules/graphql/graphql-federation/",
|
||||
"modules/graphql/graphql-persisted-operation/",
|
||||
"modules/graphql/graphql-rsocket/",
|
||||
]:
|
||||
check(f"stable excludes {forbidden}", forbidden not in stable)
|
||||
|
||||
for required in [
|
||||
"modules/graphql-advanced/graphql-persisted-operation/",
|
||||
"modules/graphql-advanced/graphql-websocket/",
|
||||
"modules/graphql-advanced/graphql-subscription/",
|
||||
"modules/graphql-advanced/graphql-federation/",
|
||||
"modules/graphql-advanced/graphql-rsocket/",
|
||||
]:
|
||||
check(f"advanced includes {required}", required in advanced)
|
||||
|
||||
# Stable coverage
|
||||
stable_required_terms = [
|
||||
"GraphQlRequestContext",
|
||||
"GraphQlClientPolicy",
|
||||
"GraphQlSchemaContract",
|
||||
"SchemaMappingInspector",
|
||||
"@oneOf",
|
||||
"GraphQlHttpProfile",
|
||||
"application/graphql-response+json",
|
||||
"GraphQlExecutionProfile",
|
||||
"GraphQlWireError",
|
||||
"GraphQlTenantIsolationPolicy",
|
||||
"GraphQlParserLimits",
|
||||
"GraphQlComplexityCalculator",
|
||||
"GraphQlRuntimeBudget",
|
||||
"GraphQlPreparsedCacheKey",
|
||||
"GraphQlBatchPolicy",
|
||||
"GraphQlFetchProfile",
|
||||
"HmacGraphQlCursorCodec",
|
||||
"GraphQlConnection",
|
||||
"GraphQlMutationIdempotencyContext",
|
||||
"GraphQlMetricCardinalityPolicy",
|
||||
"GraphQlPlatformStartupValidator",
|
||||
"GraphQlReleaseGate",
|
||||
]
|
||||
for term in stable_required_terms:
|
||||
check(f"stable coverage {term}", term in stable)
|
||||
|
||||
advanced_required_terms = [
|
||||
"GraphQlPersistedOperation",
|
||||
"GraphQlWebSocketProtocol",
|
||||
"GraphQlSubscriptionBufferPolicy",
|
||||
"GraphQlSubscriptionOrderingProfile",
|
||||
"GraphQlSseConnectionPolicy",
|
||||
"GraphQlReplayPosition",
|
||||
"GraphQlDataLoaderDependencyGraph",
|
||||
"GraphQlFederationEntityKey",
|
||||
"GraphQlFederationCompositionGate",
|
||||
"GraphQlGeneratedSourceBoundary",
|
||||
"GraphQlRepositoryAllowlist",
|
||||
"GraphQlRSocketRoutePolicy",
|
||||
"GraphQlHttpGetOperationPolicy",
|
||||
"GraphQlIncrementalCompatibilityGate",
|
||||
"GraphQlAdvancedReleaseGate",
|
||||
]
|
||||
for term in advanced_required_terms:
|
||||
check(f"advanced coverage {term}", term in advanced)
|
||||
|
||||
# Prohibited API patterns
|
||||
prohibited_patterns = [
|
||||
(r"interface\s+GenericGraphQlRepository", "no generic graphql repository"),
|
||||
(r"public\s+.*\bEntityManager\b", "no public entity manager"),
|
||||
(r"public\s+.*\bMongoTemplate\b", "no public mongo template"),
|
||||
(r"scalar\s+Upload\b", "no upload scalar declaration"),
|
||||
(r"@Transactional\s+.*GraphQL request", "no request-wide transaction implementation"),
|
||||
]
|
||||
for pattern, name in prohibited_patterns:
|
||||
check(name, re.search(pattern, stable, re.IGNORECASE | re.MULTILINE) is None)
|
||||
|
||||
# File hashes can be printed for package evidence
|
||||
for path in [DESIGN, STABLE, ADVANCED]:
|
||||
if path.exists():
|
||||
digest = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
check(f"sha256 computed: {path.name}", len(digest) == 64, digest)
|
||||
|
||||
failed = [(n, d) for n, ok, d in checks if not ok]
|
||||
print(f"CHECKS={len(checks)}")
|
||||
print(f"PASSED={len(checks)-len(failed)}")
|
||||
print(f"FAILED={len(failed)}")
|
||||
for name, detail in failed:
|
||||
print(f"FAIL: {name}" + (f" :: {detail}" if detail else ""))
|
||||
|
||||
sys.exit(1 if failed else 0)
|
||||
@@ -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 |
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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
|
||||
@@ -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 통합 시험 결과를 포함하지 않습니다.
|
||||
@@ -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
|
||||
```
|
||||
+1532
File diff suppressed because it is too large
Load Diff
+4762
File diff suppressed because it is too large
Load Diff
+1656
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+286
@@ -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)
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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 1–53
|
||||
→ Stable Release Gate
|
||||
→ Advanced Task 1–22
|
||||
→ 기능별 Promotion Gate
|
||||
```
|
||||
|
||||
## 문서
|
||||
|
||||
- `docs/superpowers/specs/2026-08-14-websocket-realtime-connection-platform-design.md`
|
||||
- `docs/superpowers/plans/2026-08-14-websocket-realtime-connection-platform-implementation-plan.md`
|
||||
- `docs/superpowers/plans/2026-08-14-websocket-advanced-capabilities-expansion-plan.md`
|
||||
- `research/source-websocket-deep-research.md`
|
||||
|
||||
## 검증
|
||||
|
||||
```bash
|
||||
python validate_websocket_docs.py
|
||||
sha256sum -c MANIFEST.sha256
|
||||
```
|
||||
|
||||
이 검증은 문서 구조·계약 일관성·패키지 무결성 검증이며 실제 Gradle compile, Browser, Nginx, Container, Fault, Performance 실행을 대체하지 않는다.
|
||||
@@ -0,0 +1,47 @@
|
||||
# WebSocket Superpowers 문서 정적 검증 결과
|
||||
|
||||
- **검증일:** 2026-08-14
|
||||
- **검증 명령:** `python validate_websocket_docs.py`
|
||||
- **검증 출력:** `checks=719 passed=719 failed=0`
|
||||
- **결과:** PASS
|
||||
|
||||
## 문서 규모
|
||||
|
||||
| 문서 | 행 | Task | Create 경로 |
|
||||
|---|---:|---:|---:|
|
||||
| 설계서 | 2,810 | - | - |
|
||||
| Stable 구현 계획 | 4,293 | 53 | 121 |
|
||||
| Advanced 확장 계획 | 1,817 | 22 | 48 |
|
||||
|
||||
## 검증 항목
|
||||
|
||||
- Stable Task 1–53 번호 연속성
|
||||
- Advanced Task 1–22 번호 연속성
|
||||
- 모든 Task의 `Files`, `Interfaces`, `Implementation requirements`, Step 1–5, commit 명령
|
||||
- Stable·Advanced Create 경로 중복 및 충돌 부재
|
||||
- `TODO`, `TBD`, `FIXME` placeholder 부재
|
||||
- Markdown code fence 균형
|
||||
- Stable Raw Typed JSON·Evidence·Ticket·Queue·Runtime·Nginx·Browser 계약 포함
|
||||
- Advanced Resume·Cluster·STOMP·Broker Relay·Binary·Compression·HTTP/2·3 계약 포함
|
||||
- 요구사항 원본 Appendix 및 research file 보존
|
||||
- SHA-256 manifest와 ZIP CRC 검증 가능 구조
|
||||
|
||||
## 검증 범위의 한계
|
||||
|
||||
현재 검증은 설계서와 구현 계획서의 정적 구조·내부 계약·패키지 무결성 검증이다. 실제 Backend Skeleton 저장소가 제공되지 않았으므로 다음은 실행하지 않았다.
|
||||
|
||||
```text
|
||||
Gradle configuration·compile
|
||||
Spring Boot ApplicationContext
|
||||
Tomcat·Jetty·Reactor Netty WebSocket contract
|
||||
Nginx TLS Upgrade path
|
||||
Chromium·Firefox·WebKit browser matrix
|
||||
Redis ticket·session index integration
|
||||
JPA result ledger transaction
|
||||
Messaging replay/fan-out
|
||||
Commit 후 socket reset fault
|
||||
Slow consumer·memory·latency performance
|
||||
STOMP·RabbitMQ broker relay
|
||||
HTTP/2·HTTP/3 compatibility
|
||||
Git commit
|
||||
```
|
||||
+1817
File diff suppressed because it is too large
Load Diff
+4293
File diff suppressed because it is too large
Load Diff
+2810
File diff suppressed because it is too large
Load Diff
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)
|
||||
@@ -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.
|
||||
@@ -0,0 +1,180 @@
|
||||
# WebSocket platform: how the design maps onto this repository
|
||||
|
||||
The design models the platform as eighteen Gradle modules under `modules/websocket`. This
|
||||
repository's fail-closed registry (`src/config/architecture/modules.json`) owns the leaf list, so
|
||||
those modules are packages inside the registered `:adapter:inbound:websocket` leaf — the same
|
||||
resolution the JPA, GraphQL and web platforms reached.
|
||||
|
||||
That is only honest if the boundaries are machine-checked, so `WebSocketStableModule` declares each
|
||||
module's package, its purity grade and its exact allowed edges, and `WebSocketModuleBoundaryTest`
|
||||
scans the source tree and fails when the two disagree in either direction. Promoting a package to
|
||||
its own Gradle leaf later is a registry edit rather than an archaeology exercise.
|
||||
|
||||
## Where the module map deviates from the design, and why
|
||||
|
||||
| Design places it in | Here | Reason |
|
||||
| --- | --- | --- |
|
||||
| `WebSocketSubprotocolName` in `websocket-protocol` | `core` | The connection context must name the negotiated token, and the context is core — leaving the type in `protocol` made `core` depend on `protocol` while `protocol` already depended on `core`. The boundary test refused the cycle. The negotiation *policy* stays in `protocol`. |
|
||||
| `StrictWebSocketJsonCodec` in `websocket-protocol` | `codec` (its own FRAMEWORK_BOUND module) | `protocol` is CORE here, and a Jackson import in a CORE module is refused. Splitting is better than relaxing the rule: the envelope's field rules stay testable with no mapper, and everything that touches a parser sits in one package a reviewer can read end to end. |
|
||||
| `budget -> core` | `core -> budget` | `budget` imports nothing from `core` — numbers depend on nothing. The endpoint profile, which is core, has to name a budget. The declared direction was simply backwards. |
|
||||
| — | `stomp` module | The pre-existing STOMP-over-SockJS channel predates this platform and still ships. Declared so the boundary is complete rather than excused; it has no edge to any platform module and none to it. |
|
||||
|
||||
## What the design's rules actually prevent
|
||||
|
||||
A few of the design's requirements read as style and are not. These are the ones worth keeping.
|
||||
|
||||
**`websocket-core-api` names no framework.** Stricter here than in the HTTP platform, because a
|
||||
connection is a long-lived object owned by a container and reaching for the container's own session
|
||||
type is tempting from everywhere. A CORE module has no `WebSocketSession`, so the same decision
|
||||
serves both runtimes and is testable without a server. The detector's framework list was missing
|
||||
`tools.jackson` (this repo runs Jackson 3, not 2) — a CORE module could have imported a mapper
|
||||
unnoticed. Fixed in both this leaf and the web leaf.
|
||||
|
||||
**No Java class name on the wire.** A FQCN publishes the package layout, breaks every client on a
|
||||
rename, and makes the receiver's type resolution an attack surface. `WebSocketMessageType` refuses
|
||||
anything that looks like one; the manifest binds published names to records, and records cannot run
|
||||
code while being populated.
|
||||
|
||||
**The payload is an encoded string, not a `Map`.** A map accepts any shape, defers validation to
|
||||
whichever handler reaches for a missing key, makes an entity trivially serializable onto the wire,
|
||||
and brings unbounded nesting with it.
|
||||
|
||||
**Handlers cannot write.** `WebSocketHandlerContext` has no session and no write method. This is
|
||||
what makes ordering, backpressure and the drain sequence guarantees rather than conventions — a
|
||||
handler that could write directly would bypass the queue, and every promise would hold only for the
|
||||
handlers that cooperated.
|
||||
|
||||
## Two findings from building it
|
||||
|
||||
**Tomcat's graceful shutdown does not close WebSocket connections.** It waits for in-flight
|
||||
*requests*, and an established WebSocket is not a request — so the shutdown completes with the
|
||||
connections still open and they die when the socket is torn down. The client sees a **1006**,
|
||||
indistinguishable from a network failure, which sends it into its most aggressive reconnect path at
|
||||
exactly the moment the fleet is restarting. `PlatformWebSocketHandler` therefore implements
|
||||
`SmartLifecycle` and closes its own connections with **1001 going away**, at a phase that runs
|
||||
before the web server stops. Found by `TomcatWebSocketAbuseIT`, which failed on its first run.
|
||||
|
||||
**Both runtimes on one classpath is a silent outage.** Spring Boot deduces one application type
|
||||
from what is present, and picks the servlet one. A deployment that declared reactive endpoints and
|
||||
shipped both starts, reports healthy, and never answers. `WebSocketStackExclusivity` reads what the
|
||||
classpath will actually produce and fails startup with a sentence explaining it.
|
||||
|
||||
## Lanes
|
||||
|
||||
```bash
|
||||
cd src
|
||||
./gradlew :adapter:inbound:websocket:test # unit, boundary, architecture, Tomcat runtime
|
||||
./gradlew :adapter:inbound:websocket:websocketJettyTest # the second servlet container
|
||||
./gradlew :adapter:inbound:websocket:websocketNginxTest # real Nginx; needs Docker, fails without it
|
||||
```
|
||||
|
||||
The Nginx lane carries a deliberately broken configuration alongside the correct one
|
||||
(`nginx-no-upgrade.conf`) so the lane proves its own assertions can fail. A contract that only ever
|
||||
runs against a correct configuration cannot tell whether it is checking anything.
|
||||
|
||||
## The Stable behaviours that had no code
|
||||
|
||||
A late audit compared every named type in the Stable plan against the source tree rather than
|
||||
against memory, and found six that nothing implemented. They are listed because the way they were
|
||||
missed is more useful than the fact that they were: each is a *behaviour under failure*, and the
|
||||
plan named it inside a task whose other half was already built — so the task read as done.
|
||||
|
||||
| Behaviour | Where it lives now |
|
||||
| --- | --- |
|
||||
| A queue observation an operator can read, separating a configured drop from a lossless overflow | `outbound/OutboundQueueSnapshot` + `OutboundQueue.snapshot()` |
|
||||
| A timed-out correlation id that is remembered, so a late answer is not attached to a reused id | `handler/LateResponseTombstone` |
|
||||
| Pooled-buffer retention on the reactive stack, bounded and counted | `webflux/WebSocketDataBufferPolicy` + `WebSocketDataBufferLifecycle` |
|
||||
| What a proxy in front of this platform has to do | `release/WebSocketNginxProxyProfile` |
|
||||
| What a rolling restart has to demonstrate, in order | `release/WebSocketRollingRestartScenario` |
|
||||
| What the platform must show before promotion | `release/WebSocketStableReleaseGate` |
|
||||
|
||||
The three `release` types share a module identity (`RELEASE`, CORE) that names no other module.
|
||||
That is deliberate: a gate importing the parts it gates would be satisfiable by construction — the
|
||||
evidence and the checklist would come from the same source. They are predicates over facts a release
|
||||
engineer supplies, so a missing runtime stays a missing runtime.
|
||||
|
||||
`WebSocketStableReleaseGate` overlaps `advanced/release/AdvancedPromotionGate` in shape and differs
|
||||
in one condition that matters. Advanced capabilities are off unless a deployment names them, so a
|
||||
broken one affects whoever enabled it; Stable is what every deployment gets, so its evidence has to
|
||||
cover every runtime it claims — Tomcat, Jetty, Reactor Netty and Nginx — rather than the one the
|
||||
author happened to test. It also refuses to promote an artifact containing an Advanced type. WS-ARCH-6
|
||||
already refuses that as a source edge; nothing in it notices a type that arrived through packaging,
|
||||
and the effect is identical — Stable that does not build without Advanced is a naming convention.
|
||||
|
||||
## Not yet implemented
|
||||
|
||||
**Tasks 47–48, the browser matrix.** The design asks for a protocol test client driven through
|
||||
Chromium, Firefox and WebKit. Not built: it needs Playwright and a browser download per engine,
|
||||
which is a dependency and a network requirement this template does not otherwise carry, and a
|
||||
browser lane that silently skips when the browsers are absent is worth less than no lane. The
|
||||
properties it would cover that the Java client does not — that a browser cannot set headers on the
|
||||
`WebSocket` constructor, and that it surfaces close codes to page script — are the reasons
|
||||
`ONE_TIME_TICKET` and the standard close codes exist, and both are asserted at the unit level.
|
||||
|
||||
## Advanced capabilities
|
||||
|
||||
The Advanced expansion plan asks for fifteen Gradle modules under `modules/websocket-advanced/`.
|
||||
They are packages under `advanced.**` in this leaf, for the same reason the Stable platform is —
|
||||
`src/config/architecture/modules.json` is fail-closed and owns the leaf list, and a fifteen-leaf
|
||||
addition to satisfy a directory layout is a change to the registry, not to the architecture. The
|
||||
separation the design wanted is enforced by `WebSocketStableModule` and by WS-ARCH-6, which fails
|
||||
the build when a Stable class names an Advanced one. A feature flag decides whether a bean exists;
|
||||
it does nothing about a compile-time edge, and ArchUnit does.
|
||||
|
||||
Three of the fifteen needed their own module identity rather than sharing `advanced`:
|
||||
|
||||
- **`advanced-stomp`** (`advanced.stomp`) is `FRAMEWORK_BOUND`. STOMP here *is* Spring Messaging, and
|
||||
folding it into `advanced` would have relaxed that module's purity for every capability in it.
|
||||
- **`advanced-stomp-rabbit`** (`advanced.stomp.rabbit`) is separate again. The adapter parses a
|
||||
protocol; the relay opens a TCP connection to somebody else's broker and makes every delivery
|
||||
depend on it. Different blast radius, so a deployment can refuse one and keep the other.
|
||||
- Everything else resolves to `advanced` by longest-prefix, which is what lets `advanced.codec.cbor`
|
||||
exist without its own edge set.
|
||||
|
||||
### What was adapted rather than copied
|
||||
|
||||
**Task 8's presence record shape.** The design specifies
|
||||
`(actorFingerprint, activeConnectionCount, state, lastObservedAt)` with a four-state observation
|
||||
model. Implemented with `WebSocketActorReference` in place of a bare fingerprint string — it carries
|
||||
the fingerprint and refuses to be constructed from raw identity, which is the property the design
|
||||
was buying with the field name. The four states are implemented as specified; `PresenceState.STALE`
|
||||
and `OFFLINE` are distinct because collapsing them reports every user as disconnected during a Redis
|
||||
partition, when the connections are fine and the index went dark.
|
||||
|
||||
**Task 14's Protobuf codec.** The descriptor compatibility gate and the profile are implemented and
|
||||
tested. The encode/decode path is not: a Protobuf codec without generated message classes has
|
||||
nothing to encode, and generating them requires a `.proto` source this template does not have and
|
||||
should not invent. `DescriptorCompatibilityGate` is the part with the failure mode worth guarding —
|
||||
a changed or reused field number reinterprets bytes already on the wire, and during a rolling deploy
|
||||
both descriptor versions are live, so the receiver reads the wrong field without erroring.
|
||||
|
||||
**Task 15's CBOR codec.** Same shape, same reason: `CborCodecProfile` fixes canonical encoding and
|
||||
the duplicate-key policy, which are the two settings that decide whether two systems reading the
|
||||
same bytes agree. `WebSocketCborCodec` implements the encode/decode path on top of it, and
|
||||
`SchemaParity` is what stops it publishing a different catalog than JSON.
|
||||
|
||||
`jackson-dataformat-cbor` and `protobuf-java` are `compileOnly` plus `testImplementation`, not
|
||||
`implementation`. Both jars change an adopter's behaviour by their mere presence — Spring Boot
|
||||
registers a `cborMapper` bean for the first and Spring registers a Protobuf message converter for
|
||||
the second — so an adopting composition root would acquire both without enabling either capability.
|
||||
The sibling web leaf shipped exactly that mistake and it broke the composition root outright; see
|
||||
`docs/web/repository-adaptation.md`. `WebSocketBinaryCodecBackend` turns an absent backend into a
|
||||
sentence naming the missing coordinate, and `BinaryCodecBackendScopeTest` reads `build.gradle` and
|
||||
fails if either returns to `implementation`.
|
||||
|
||||
**Task 12's relay configuration.** `RabbitBrokerRelayConfiguration` contributes only the broker, not
|
||||
the destination prefixes. Two `WebSocketMessageBrokerConfigurer` beans each setting the application
|
||||
prefix produce whichever ran last, silently — the same failure `StompBrokerExclusivity` exists to
|
||||
catch between the two STOMP channels.
|
||||
|
||||
### Verification
|
||||
|
||||
```bash
|
||||
cd src
|
||||
./gradlew :adapter:inbound:websocket:test # 509 tests, Advanced included
|
||||
./gradlew :adapter:inbound:websocket:websocketJettyTest
|
||||
./gradlew :adapter:inbound:websocket:websocketNginxTest
|
||||
```
|
||||
|
||||
See `docs/adr/ADR-WS-002-resume-and-cluster.md`, `docs/adr/ADR-WS-003-stomp-and-broker-relay.md`,
|
||||
`docs/websocket/advanced-support-matrix.md` and `docs/websocket/runbooks/`.
|
||||
@@ -0,0 +1,107 @@
|
||||
# WebSocket platform runbook
|
||||
|
||||
Organised by what you observe, because that is what you have during an incident.
|
||||
|
||||
## Clients report 1006 — "closed abnormally, no reason"
|
||||
|
||||
The most important symptom here, because 1006 is indistinguishable from a network failure and
|
||||
drives every client into its most aggressive reconnect path.
|
||||
|
||||
- **During a deploy** — the platform did not close its connections. `PlatformWebSocketHandler`
|
||||
implements `SmartLifecycle` and closes with **1001 going away**; a container's own graceful
|
||||
shutdown does **not** do this, because it waits for in-flight *requests* and an established
|
||||
WebSocket is not a request. Check the handler bean is registered and that its phase runs before
|
||||
the web server stops.
|
||||
- **Behind a proxy** — the upgrade is mishandled, so the close frame never becomes a close frame.
|
||||
See the next section.
|
||||
- **Neither** — something threw while closing. A close reason longer than 123 bytes makes the
|
||||
container throw mid-close and turns a clean refusal into a 1006; the platform truncates for that
|
||||
reason.
|
||||
|
||||
## Connections work locally and not behind the load balancer
|
||||
|
||||
Three nginx directives, each producing a different failure:
|
||||
|
||||
| Missing | Symptom |
|
||||
| --- | --- |
|
||||
| `proxy_http_version 1.1` | Handshake answered 400, or simply not upgraded. HTTP/1.0 has no Upgrade mechanism. |
|
||||
| `Upgrade` / `Connection` forwarded | Upstream sees an ordinary GET and answers 404 or 200 for a route that works when tested directly. They are hop-by-hop headers, so a proxy is *required* to drop them. |
|
||||
| `proxy_read_timeout` raised | Every connection quieter than 60s is killed with no close frame. A WebSocket is idle by nature. |
|
||||
|
||||
`websocketNginxTest` asserts all three against a real Nginx, and carries a deliberately broken
|
||||
configuration so it proves its own assertions can fail.
|
||||
|
||||
## Connections vanish with no close frame and no error
|
||||
|
||||
Almost always an intermediary's idle timeout, not the application. TCP does not report a departed
|
||||
peer — a closed laptop lid, a phone switching to cellular and a NAT forgetting its mapping all
|
||||
produce no FIN.
|
||||
|
||||
- Check `HeartbeatPolicy`: the ping interval must be under ~20s, because intermediaries commonly
|
||||
drop idle connections at 30–60s and say nothing.
|
||||
- The idle timeout must be at least two ping intervals. Below that, one dropped ping on a congested
|
||||
network closes a healthy connection, and the reconnect storm makes the congestion worse.
|
||||
|
||||
## Memory grows and nothing is failing
|
||||
|
||||
A slow consumer. It does not error — it reads more slowly than the server writes and the difference
|
||||
accumulates. Arranging it requires no tooling: reading slowly is enough.
|
||||
|
||||
- **Check first:** `WebSocketNodeSnapshot.droppedOutboundMessages`. Connection counts and buffered
|
||||
bytes both look healthy while data is being lost; the drop count is the only number that says so.
|
||||
- **Bounds:** per connection (`maxBufferedOutboundBytes`) *and* node-wide (`GlobalBufferBudget`).
|
||||
The second is not redundant — a megabyte each is fine at a hundred connections and is the whole
|
||||
heap at fifty thousand.
|
||||
- **If drops are zero and memory still grows:** check that the queue lock and the socket lock are
|
||||
separate. When they were one lock, a stalled peer blocked every producer, the queue never filled,
|
||||
and the shedding bound never fired. That is fixed, and it is the shape to look for if it recurs.
|
||||
|
||||
## A command ran twice
|
||||
|
||||
The reconnect is what makes this a WebSocket problem: a client replays everything it never saw an
|
||||
answer for, on a new connection, all at once.
|
||||
|
||||
1. **Key scoped to the connection?** It must not be. `WebSocketCommandKey` is endpoint + actor +
|
||||
the client's own message id, so it survives the reconnect.
|
||||
2. **Ledger not transactional?** The only implementation that works writes in the application's own
|
||||
transaction. Anything else has a window between the business commit and the ledger write.
|
||||
3. **Outcome `UNKNOWN`?** That is the window. Neither replaying nor re-running is safe — replaying
|
||||
invents a result, re-running duplicates a committed write. Only `CommandReconciliation` reading
|
||||
the business data can settle it, and `INDETERMINATE` is a real answer to act on.
|
||||
|
||||
## A client cannot connect and the endpoint is definitely there
|
||||
|
||||
- **403** — origin. The same-origin policy does **not** protect a WebSocket handshake and there is
|
||||
no preflight, so the server's `Origin` check is the entire defence and it is strict: exact match,
|
||||
lowercase, no path, no wildcard.
|
||||
- **401** — the ticket was spent, expired (30s cap), or minted for another endpoint. All three are
|
||||
deliberate; a longer-lived or unbound ticket is a bearer token in a query string again.
|
||||
- **400** — no offered subprotocol is supported. Production refuses a client that offers none.
|
||||
- **503** — the endpoint is at its connection cap.
|
||||
|
||||
Order matters and is asserted: route → origin → capacity → credential → subprotocol. Origin is
|
||||
checked before authentication because a cookie handshake from an attacker's page authenticates
|
||||
perfectly; the credential is valid and the page is not allowed to use it.
|
||||
|
||||
## Endpoints never answer and the app reports healthy
|
||||
|
||||
Both runtimes on one classpath. Spring Boot deduces one application type and picks the servlet one,
|
||||
so declared reactive endpoints are simply never served — no error, no warning.
|
||||
`WebSocketStackExclusivity` fails startup on this; if a running instance has it, the check is not
|
||||
wired.
|
||||
|
||||
## Metrics stopped arriving
|
||||
|
||||
A high-cardinality tag, and the risk is worse than for HTTP: a request produces one observation,
|
||||
a connection produces them for hours, so the series outlive the connections and only accumulate.
|
||||
`WebSocketMetricTags` allows eight names; `connectionId`, `sessionId` and `actor` are not among
|
||||
them. `nodeId` is, because a fleet has a knowable number of nodes.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd src
|
||||
./gradlew :adapter:inbound:websocket:test # unit, boundary, architecture, Tomcat runtime
|
||||
./gradlew :adapter:inbound:websocket:websocketJettyTest # second servlet container
|
||||
./gradlew :adapter:inbound:websocket:websocketNginxTest # real Nginx; needs Docker, fails without it
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
+84
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+116
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+64
@@ -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();
|
||||
}
|
||||
}
|
||||
+28
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+27
@@ -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;
|
||||
}
|
||||
}
|
||||
+88
@@ -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;
|
||||
}
|
||||
}
|
||||
+35
@@ -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;
|
||||
}
|
||||
}
|
||||
+57
@@ -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;
|
||||
}
|
||||
}
|
||||
+95
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+102
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+65
-33
@@ -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,
|
||||
|
||||
+10
@@ -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;
|
||||
|
||||
+169
@@ -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();
|
||||
}
|
||||
}
|
||||
+214
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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=
|
||||
|
||||
@@ -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.'
|
||||
|
||||
@@ -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=
|
||||
|
||||
+30
@@ -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);
|
||||
}
|
||||
}
|
||||
+26
@@ -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";
|
||||
}
|
||||
}
|
||||
+28
@@ -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);
|
||||
}
|
||||
}
|
||||
+48
@@ -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();
|
||||
}
|
||||
}
|
||||
+27
@@ -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);
|
||||
}
|
||||
}
|
||||
+28
@@ -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);
|
||||
}
|
||||
}
|
||||
+63
@@ -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();
|
||||
}
|
||||
}
|
||||
+41
@@ -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
|
||||
+50
@@ -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();
|
||||
}
|
||||
}
|
||||
+62
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+18
@@ -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);
|
||||
}
|
||||
}
|
||||
+138
@@ -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);
|
||||
}
|
||||
}
|
||||
+69
@@ -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;
|
||||
}
|
||||
}
|
||||
+104
@@ -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();
|
||||
}
|
||||
}
|
||||
+59
@@ -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);
|
||||
}
|
||||
}
|
||||
+21
@@ -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();
|
||||
}
|
||||
+80
@@ -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));
|
||||
}
|
||||
}
|
||||
+118
@@ -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();
|
||||
}
|
||||
}
|
||||
+21
@@ -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);
|
||||
}
|
||||
+74
@@ -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;
|
||||
}
|
||||
}
|
||||
+47
@@ -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);
|
||||
}
|
||||
}
|
||||
+97
@@ -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();
|
||||
}
|
||||
}
|
||||
+50
@@ -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);
|
||||
}
|
||||
}
|
||||
+47
@@ -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
Reference in New Issue
Block a user