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

This commit is contained in:
DongHyeonka
2026-08-28 17:01:27 +09:00
parent 0137263441
commit a24ece9cf7
883 changed files with 100584 additions and 2623 deletions
+4
View File
@@ -72,6 +72,10 @@ dependencies {
// a test-only authentication/CORS composition. Security remains a composition-root concern;
// this dependency does not add production security policy to the opt-in GraphQL adapter.
testImplementation 'org.springframework.boot:spring-boot-starter-security'
// TestRestTemplate needs RestTemplateBuilder, and spring-boot-resttestclient stopped
// bringing it transitively in Spring Boot 4.0.x — the capability is still supported, its
// dependency is simply no longer implicit. A module that autowires TestRestTemplate says so.
testImplementation 'org.springframework.boot:spring-boot-restclient'
// A real MeterRegistry, so the cardinality claim is measured rather than asserted. Only
// micrometer-observation is on the production classpath; a registry that actually stores series
+86 -85
View File
@@ -2,19 +2,18 @@
# Manual edits can break the build and are not advised.
# This file is expected to be part of source control.
biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,testCompileClasspath,testFixturesCompileClasspath
ch.qos.logback:logback-classic:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
ch.qos.logback:logback-core:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
com.fasterxml.jackson.core:jackson-annotations:2.20=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor
ch.qos.logback:logback-classic:1.5.38=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
ch.qos.logback:logback-core:1.5.38=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
com.fasterxml.jackson.core:jackson-annotations:2.21=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor
com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs
com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,testCompileClasspath,testFixturesCompileClasspath
com.github.spotbugs:spotbugs:4.10.2=spotbugs
com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs
com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor
com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor
com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor
com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,spotbugs,testCompileClasspath,testFixturesCompileClasspath
com.google.code.findbugs:jsr305:3.0.2=checkstyle,spotbugs
com.google.code.gson:gson:2.13.2=spotbugs
com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor
com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,testCompileClasspath,testFixturesCompileClasspath
@@ -33,27 +32,27 @@ com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProce
com.graphql-java:graphql-java:25.0=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
com.graphql-java:java-dataloader:6.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins
com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath
com.jayway.jsonpath:json-path:2.10.0=testCompileClasspath,testRuntimeClasspath
com.puppycrawl.tools:checkstyle:13.5.0=checkstyle
com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath
commons-beanutils:commons-beanutils:1.11.0=checkstyle
commons-collections:commons-collections:3.2.2=checkstyle
commons-io:commons-io:2.21.0=spotbugs
commons-logging:commons-logging:1.3.5=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
commons-logging:commons-logging:1.3.6=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
info.picocli:picocli:4.7.7=checkstyle
io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor
io.micrometer:context-propagation:1.2.0=runtimeClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
io.micrometer:micrometer-core:1.16.0=testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
io.projectreactor:reactor-core:3.8.0=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
io.micrometer:context-propagation:1.2.1=runtimeClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
io.micrometer:micrometer-commons:1.16.7=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
io.micrometer:micrometer-core:1.16.7=testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-observation:1.16.7=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
io.projectreactor:reactor-core:3.8.7=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
jakarta.activation:jakarta.activation-api:2.1.4=testCompileClasspath,testRuntimeClasspath
jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
jakarta.servlet:jakarta.servlet-api:6.1.0=compileClasspath
jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=testCompileClasspath,testRuntimeClasspath
jakarta.xml.bind:jakarta.xml.bind-api:4.0.5=testCompileClasspath,testRuntimeClasspath
javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor
jaxen:jaxen:2.0.0=spotbugs
jaxen:jaxen:2.0.6=spotbugs
net.bytebuddy:byte-buddy-agent:1.17.8=testCompileClasspath,testRuntimeClasspath
net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath
net.minidev:accessors-smart:2.6.0=testCompileClasspath,testRuntimeClasspath
@@ -66,19 +65,19 @@ org.apache.commons:commons-text:1.15.0=spotbugs
org.apache.commons:commons-text:1.3=checkstyle
org.apache.httpcomponents:httpclient:4.5.13=checkstyle
org.apache.httpcomponents:httpcore:4.4.16=checkstyle
org.apache.logging.log4j:log4j-api:2.25.2=compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.apache.logging.log4j:log4j-core:2.25.2=spotbugs
org.apache.logging.log4j:log4j-to-slf4j:2.25.2=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.apache.logging.log4j:log4j-api:2.25.5=compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.apache.logging.log4j:log4j-core:2.25.5=spotbugs
org.apache.logging.log4j:log4j-to-slf4j:2.25.5=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.apache.maven.doxia:doxia-core:1.12.0=checkstyle
org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle
org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle
org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle
org.apache.tomcat.embed:tomcat-embed-core:11.0.14=testCompileClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-el:11.0.14=testCompileClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=testCompileClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-core:11.0.24=testCompileClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-el:11.0.24=testCompileClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-websocket:11.0.24=testCompileClasspath,testRuntimeClasspath
org.apache.xbean:xbean-reflect:3.7=checkstyle
org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath
org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath
org.assertj:assertj-core:3.27.7=testCompileClasspath,testRuntimeClasspath
org.awaitility:awaitility:4.3.0=testCompileClasspath,testRuntimeClasspath
org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle
org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle
@@ -88,15 +87,15 @@ org.dom4j:dom4j:2.2.0=spotbugs
org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath
org.hdrhistogram:HdrHistogram:2.2.2=testRuntimeClasspath
org.javassist:javassist:3.28.0-GA=checkstyle
org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testFixturesAnnotationProcessor,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath
org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath
org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath
org.junit.platform:junit-platform-engine:6.0.1=testRuntimeClasspath
org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath
org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath
org.jspecify:jspecify:1.0.1=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testFixturesAnnotationProcessor,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-api:6.0.3=testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-engine:6.0.3=testRuntimeClasspath
org.junit.jupiter:junit-jupiter-params:6.0.3=testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter:6.0.3=testCompileClasspath,testRuntimeClasspath
org.junit.platform:junit-platform-commons:6.0.3=testCompileClasspath,testRuntimeClasspath
org.junit.platform:junit-platform-engine:6.0.3=testRuntimeClasspath
org.junit.platform:junit-platform-launcher:6.0.3=testRuntimeClasspath
org.junit:junit-bom:6.0.3=testCompileClasspath,testRuntimeClasspath
org.junit:junit-bom:6.1.0=spotbugs
org.latencyutils:LatencyUtils:2.0.3=testRuntimeClasspath
org.mockito:mockito-core:5.20.0=mockitoAgent,testCompileClasspath,testRuntimeClasspath
@@ -117,61 +116,63 @@ org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor,
org.reactivestreams:reactive-streams:1.0.4=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.reflections:reflections:0.10.2=checkstyle
org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath
org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j
org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-configuration-processor:4.0.0=annotationProcessor
org.springframework.boot:spring-boot-graphql-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-graphql:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-http-codec:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-http-converter:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-jackson:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-restclient:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-security:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-servlet:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-graphql-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-graphql:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-security:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-web:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-web-server:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-webtestclient:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.springframework.graphql:spring-graphql-test:2.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.graphql:spring-graphql:2.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.springframework.security:spring-security-config:7.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-core:7.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-crypto:7.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-web:7.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework:spring-aop:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.springframework:spring-beans:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.springframework:spring-context:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.springframework:spring-core:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.springframework:spring-expression:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.springframework:spring-test:7.0.1=testCompileClasspath,testRuntimeClasspath
org.springframework:spring-web:7.0.1=testCompileClasspath,testRuntimeClasspath
org.springframework:spring-webflux:7.0.1=testCompileClasspath,testRuntimeClasspath
org.springframework:spring-webmvc:7.0.1=testCompileClasspath,testRuntimeClasspath
org.slf4j:jul-to-slf4j:2.0.18=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.slf4j:slf4j-api:2.0.18=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j
org.slf4j:slf4j-simple:2.0.18=checkstyle
org.springframework.boot:spring-boot-autoconfigure:4.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-configuration-processor:4.0.8=annotationProcessor
org.springframework.boot:spring-boot-graphql-test:4.0.8=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-graphql:4.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-http-client:4.0.8=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-http-codec:4.0.8=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-http-converter:4.0.8=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-jackson:4.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-reactor:4.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-restclient:4.0.8=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-resttestclient:4.0.8=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-security:4.0.8=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-servlet:4.0.8=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-graphql-test:4.0.8=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-graphql:4.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson-test:4.0.8=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson:4.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-logging:4.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-security:4.0.8=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-test:4.0.8=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.8=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-tomcat:4.0.8=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-web:4.0.8=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-webmvc-test:4.0.8=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-webmvc:4.0.8=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter:4.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-test-autoconfigure:4.0.8=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-test:4.0.8=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-tomcat:4.0.8=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-web-server:4.0.8=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-webmvc-test:4.0.8=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-webmvc:4.0.8=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-webtestclient:4.0.8=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot:4.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.springframework.graphql:spring-graphql-test:2.0.5=testCompileClasspath,testRuntimeClasspath
org.springframework.graphql:spring-graphql:2.0.5=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.springframework.security:spring-security-config:7.0.7=testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-core:7.0.7=testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-crypto:7.0.7=testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-web:7.0.7=testCompileClasspath,testRuntimeClasspath
org.springframework:spring-aop:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.springframework:spring-beans:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.springframework:spring-context:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.springframework:spring-core:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.springframework:spring-expression:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
org.springframework:spring-test:7.0.9=testCompileClasspath,testRuntimeClasspath
org.springframework:spring-web:7.0.9=testCompileClasspath,testRuntimeClasspath
org.springframework:spring-webflux:7.0.9=testCompileClasspath,testRuntimeClasspath
org.springframework:spring-webmvc:7.0.9=testCompileClasspath,testRuntimeClasspath
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath
org.yaml:snakeyaml:2.5=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
tools.jackson.core:jackson-core:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
tools.jackson.core:jackson-databind:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
tools.jackson:jackson-bom:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
tools.jackson.core:jackson-core:3.1.5=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
tools.jackson.core:jackson-databind:3.1.5=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
tools.jackson:jackson-bom:3.1.5=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
empty=
@@ -0,0 +1,84 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.rsocket;
import dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap.GraphQlAdvancedCapability;
import dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap.GraphQlAdvancedCapabilityDisabledException;
import dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap.GraphQlAdvancedFeatureFlags;
import java.util.Objects;
import java.util.Set;
/**
* Decides whether the experimental RSocket transport may exist, and on what terms.
*
* <p>Two gates rather than one, and the second is the reason this is a factory. RSocket is graded
* experimental here, so the capability flag alone is not enough: the deployment must also have
* given explicit experimental approval. A flag can be set by anyone editing configuration; the
* approval is a separate act, and separating them is what stops an experimental transport from
* being switched on the way a supported one would be.
*
* <p>The named-consumer list is the third condition, enforced by {@link GraphQlRSocketProperties}
* itself. An experimental transport with no named consumer is one nobody will notice breaking,
* which is the state it must not be allowed to reach.
*/
public final class GraphQlRSocketHandlerFactory {
private final GraphQlAdvancedFeatureFlags flags;
private final GraphQlRSocketProperties properties;
private final GraphQlRSocketRoutePolicy routes;
/**
* @param flags which capabilities this deployment named, and whether it approved experiments
* @param properties the transport settings, including the named consumers
* @param routes which RSocket routes reach GraphQL
*/
public GraphQlRSocketHandlerFactory(
GraphQlAdvancedFeatureFlags flags,
GraphQlRSocketProperties properties,
GraphQlRSocketRoutePolicy routes) {
this.flags = Objects.requireNonNull(flags, "flags");
this.properties = Objects.requireNonNull(properties, "properties");
this.routes = Objects.requireNonNull(routes, "routes");
}
/** Whether the transport may be installed at all. */
public boolean enabled() {
return properties.enabled() && flags.isEnabled(GraphQlAdvancedCapability.RSOCKET);
}
/**
* The settings a handler is installed with.
*
* @throws GraphQlAdvancedCapabilityDisabledException when the capability was not named, or was
* named without experimental approval
*/
public GraphQlRSocketHandlerSettings settings() {
if (!enabled()) {
throw new GraphQlAdvancedCapabilityDisabledException(
GraphQlAdvancedCapability.RSOCKET.name());
}
return new GraphQlRSocketHandlerSettings(
routes, properties.allowedMetadataMimeTypes(), properties.namedConsumers());
}
/**
* What a handler is configured with.
*
* @param routes which RSocket routes reach GraphQL
* @param allowedMetadataMimeTypes the metadata encodings accepted
* @param namedConsumers who this transport exists for
*/
public record GraphQlRSocketHandlerSettings(
GraphQlRSocketRoutePolicy routes,
Set<String> allowedMetadataMimeTypes,
Set<String> namedConsumers) {
public GraphQlRSocketHandlerSettings {
Objects.requireNonNull(routes, "routes");
allowedMetadataMimeTypes = Set.copyOf(allowedMetadataMimeTypes);
namedConsumers = Set.copyOf(namedConsumers);
if (namedConsumers.isEmpty()) {
throw new IllegalArgumentException(
"an experimental transport with no named consumer is one nobody will notice breaking");
}
}
}
}
@@ -0,0 +1,116 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.springdata;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
/**
* The repository exposures this deployment has actually decided to publish.
*
* <p>Empty by default, and that is the capability's entire safety property. Spring's
* {@code @GraphQlRepository} auto-registers a data fetcher for every annotated repository it finds,
* so without an allowlist the set of exposed fields is whatever somebody annotated — which is a
* decision made in a persistence class, by whoever was working on persistence, and reviewed as a
* persistence change.
*
* <p>Each registration carries its own argument, pagination and projection policies rather than
* sharing global ones. A coordinate that may sort by one column is not the same as one that may
* sort by another, and a single shared policy would have to be the union.
*/
public final class GraphQlRepositoryAllowlist {
private final Map<String, Registration> registrations;
private GraphQlRepositoryAllowlist(Map<String, Registration> registrations) {
this.registrations = Map.copyOf(registrations);
}
/** Nothing exposed. The default, and what a deployment that has not decided has. */
public static GraphQlRepositoryAllowlist empty() {
return new GraphQlRepositoryAllowlist(Map.of());
}
/** A builder, because an allowlist is written once at startup and read on every schema build. */
public static Builder builder() {
return new Builder();
}
/** Whether a repository is exposed at all, under any coordinate. */
public boolean contains(String repositoryName) {
return registrations.keySet().stream().anyMatch(key -> key.startsWith(repositoryName + "@"));
}
/** The registration for one exposure, empty when it was never registered. */
public Optional<Registration> find(GraphQlRepositoryExposure exposure) {
Objects.requireNonNull(exposure, "exposure");
return Optional.ofNullable(registrations.get(key(exposure)));
}
/** Every registered exposure, for the startup report. */
public List<String> exposures() {
return registrations.keySet().stream().sorted().toList();
}
/** How many exposures are registered. */
public int size() {
return registrations.size();
}
private static String key(GraphQlRepositoryExposure exposure) {
return exposure.repositoryName() + "@" + exposure.schemaCoordinate();
}
/**
* What one exposure is permitted to do.
*
* @param exposure which repository at which coordinate
* @param arguments the filters and sorts it accepts
* @param pagination how it pages
* @param projection what it may return
*/
public record Registration(
GraphQlRepositoryExposure exposure,
GraphQlRepositoryArgumentPolicy arguments,
GraphQlRepositoryPaginationPolicy pagination,
GraphQlRepositoryProjectionPolicy projection) {
public Registration {
Objects.requireNonNull(exposure, "exposure");
Objects.requireNonNull(arguments, "arguments");
Objects.requireNonNull(pagination, "pagination");
Objects.requireNonNull(projection, "projection");
}
}
/** Collects registrations and refuses a duplicate coordinate. */
public static final class Builder {
private final Map<String, Registration> registrations = new LinkedHashMap<>();
private Builder() {}
/** Register one exposure with its three policies. */
public Builder expose(
GraphQlRepositoryExposure exposure,
GraphQlRepositoryArgumentPolicy arguments,
GraphQlRepositoryPaginationPolicy pagination,
GraphQlRepositoryProjectionPolicy projection) {
Registration registration = new Registration(exposure, arguments, pagination, projection);
Registration previous = registrations.putIfAbsent(key(exposure), registration);
if (previous != null) {
throw new IllegalArgumentException(
"two registrations for "
+ key(exposure)
+ "; whichever was added last would silently decide the coordinate's limits");
}
return this;
}
/** Freeze it. */
public GraphQlRepositoryAllowlist build() {
return new GraphQlRepositoryAllowlist(registrations);
}
}
}
@@ -0,0 +1,64 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.springdata;
import java.util.Objects;
import java.util.Set;
/**
* Which filter and sort arguments a coordinate accepts.
*
* <p>The other half of the persistence-leak problem, and the half that is easier to miss. A
* query-by-example or Querydsl fetcher derives its predicate from whatever arguments arrive, so an
* un-allowlisted argument set means the client writes the {@code WHERE} clause — including over
* columns the schema never published, because the binder resolves against the entity rather than
* against the GraphQL type.
*
* <p>Sort is allowlisted separately from filter. They read alike and behave differently under load:
* a filter on an unindexed column is one slow query, and a sort on one is a full sort of the table
* on every page.
*/
public final class GraphQlRepositoryArgumentPolicy {
private final Set<String> filterableFields;
private final Set<String> sortableFields;
/**
* @param filterableFields the arguments that may narrow the result
* @param sortableFields the fields that may order it
*/
public GraphQlRepositoryArgumentPolicy(Set<String> filterableFields, Set<String> sortableFields) {
this.filterableFields =
Set.copyOf(Objects.requireNonNull(filterableFields, "filterableFields"));
this.sortableFields = Set.copyOf(Objects.requireNonNull(sortableFields, "sortableFields"));
}
/** A coordinate that accepts no filter and no sort. */
public static GraphQlRepositoryArgumentPolicy none() {
return new GraphQlRepositoryArgumentPolicy(Set.of(), Set.of());
}
/** Whether a filter argument is permitted. */
public boolean filterable(String field) {
return field != null && filterableFields.contains(field);
}
/** Whether a sort field is permitted. */
public boolean sortable(String field) {
return field != null && sortableFields.contains(field);
}
/**
* The arguments a request used that this coordinate does not accept.
*
* <p>All of them, not the first: a caller fixing a query should not discover the rejections one
* round trip at a time.
*/
public java.util.List<String> reject(Set<String> filters, Set<String> sorts) {
Objects.requireNonNull(filters, "filters");
Objects.requireNonNull(sorts, "sorts");
return java.util.stream.Stream.concat(
filters.stream().filter(field -> !filterable(field)).map(field -> "filter:" + field),
sorts.stream().filter(field -> !sortable(field)).map(field -> "sort:" + field))
.sorted()
.toList();
}
}
@@ -0,0 +1,28 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.springdata;
import java.util.Objects;
/**
* One repository offered at one schema coordinate.
*
* <p>The pair, never the repository alone. Spring's {@code @GraphQlRepository} binds a repository
* to whatever coordinate its return type matches, so the same repository reached from {@code
* Query.orders} and from {@code Query.allOrders} is two exposures with two different audiences —
* and an allowlist keyed only on the repository would authorise both from one decision.
*
* @param repositoryName the repository bean's name
* @param schemaCoordinate the {@code Type.field} it answers
*/
public record GraphQlRepositoryExposure(String repositoryName, String schemaCoordinate) {
public GraphQlRepositoryExposure {
Objects.requireNonNull(repositoryName, "repositoryName");
Objects.requireNonNull(schemaCoordinate, "schemaCoordinate");
if (repositoryName.isBlank() || schemaCoordinate.isBlank()) {
throw new IllegalArgumentException("an exposure names a repository and a coordinate");
}
if (schemaCoordinate.indexOf('.') < 1) {
throw new IllegalArgumentException("a schema coordinate is Type.field: " + schemaCoordinate);
}
}
}
@@ -0,0 +1,27 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.springdata;
import java.util.Objects;
/**
* A repository was about to be exposed as a data fetcher without being registered for it.
*
* <p>Raised at startup rather than at query time. An unregistered exposure that only failed when
* somebody queried it would be a schema field that exists, appears in introspection, and errors —
* which is worse than one that was never published.
*/
public final class GraphQlRepositoryExposureRejectedException extends RuntimeException {
private static final long serialVersionUID = 1L;
private final transient String repositoryName;
public GraphQlRepositoryExposureRejectedException(String repositoryName, String reason) {
super("repository exposure refused for '" + repositoryName + "': " + reason);
this.repositoryName = Objects.requireNonNull(repositoryName, "repositoryName");
}
/** Which repository. */
public String repositoryName() {
return repositoryName;
}
}
@@ -0,0 +1,88 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.springdata;
import java.util.List;
import java.util.Objects;
import java.util.Set;
/**
* Refuses a repository exposure the deployment did not register.
*
* <p>Runs at schema build, not at query time. An unregistered exposure that only failed when
* somebody queried it would be a field that exists, appears in introspection and errors — which is
* worse than one that was never published, because a client has already written code against it.
*
* <p>This whole capability is a compatibility path and is documented as one. It exists so an
* existing Spring Data GraphQL application can be brought onto this platform without rewriting
* every fetcher on day one; it is not the mainstream way to serve a field here, and a coordinate
* that stays on it indefinitely is a coordinate whose application-service boundary was never
* written.
*/
public final class GraphQlRepositoryExposureValidator {
private final GraphQlRepositoryAllowlist allowlist;
public GraphQlRepositoryExposureValidator(GraphQlRepositoryAllowlist allowlist) {
this.allowlist = Objects.requireNonNull(allowlist, "allowlist");
}
/**
* Require an exposure to be registered.
*
* @throws GraphQlRepositoryExposureRejectedException when it is not
*/
public GraphQlRepositoryAllowlist.Registration verify(GraphQlRepositoryExposure exposure) {
Objects.requireNonNull(exposure, "exposure");
return allowlist
.find(exposure)
.orElseThrow(
() ->
new GraphQlRepositoryExposureRejectedException(
exposure.repositoryName(),
"no registration for coordinate "
+ exposure.schemaCoordinate()
+ "; a repository is exposed by a deployment decision, not by an"
+ " annotation on a persistence class"));
}
/**
* Check one request against the coordinate's registered limits.
*
* @param exposure which repository at which coordinate
* @param filters the filter arguments the request used
* @param sorts the sort fields it used
* @param pageSize the page size it asked for
* @param returnTypeName the type the fetcher would return
* @throws GraphQlRepositoryExposureRejectedException naming every violation at once
*/
public void verifyRequest(
GraphQlRepositoryExposure exposure,
Set<String> filters,
Set<String> sorts,
int pageSize,
String returnTypeName) {
GraphQlRepositoryAllowlist.Registration registration = verify(exposure);
List<String> refused =
new java.util.ArrayList<>(registration.arguments().reject(filters, sorts));
if (!registration.pagination().permits(pageSize)) {
refused.add("page size " + pageSize + " exceeds " + registration.pagination().maxPageSize());
}
if (!registration.projection().permits(returnTypeName)) {
// The one that keeps this from becoming a database export. Returning the entity publishes
// every column and makes a rename in the database a breaking change for every client.
refused.add(
"return type "
+ returnTypeName
+ " is not an approved projection; returning the entity publishes the persistence"
+ " model as an API");
}
if (!refused.isEmpty()) {
throw new GraphQlRepositoryExposureRejectedException(
exposure.repositoryName(), String.join("; ", refused));
}
}
/** Whether the deployment has exposed anything at all. */
public boolean anyExposure() {
return allowlist.size() > 0;
}
}
@@ -0,0 +1,35 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.springdata;
/**
* How many rows one coordinate may return, and how they are addressed.
*
* <p>Explicit, never inherited. Spring Data's GraphQL integration defaults to offset pagination
* with a page size of twenty, and a default is exactly what nobody reviews: a field that quietly
* serves twenty rows is fine until the client asks for the offset at row four hundred thousand, at
* which point the database is sorting the whole table to skip it.
*
* <p>Keyset is the recommended shape and the policy says which one is in use, because the two have
* different failure modes and an operator reading a slow query needs to know which they are looking
* at.
*
* @param keyset whether the coordinate pages by cursor rather than by offset
* @param maxPageSize the largest page a caller may request
*/
public record GraphQlRepositoryPaginationPolicy(boolean keyset, int maxPageSize) {
public GraphQlRepositoryPaginationPolicy {
if (maxPageSize < 1) {
throw new IllegalArgumentException("a page of nothing is not a page");
}
if (maxPageSize > 200) {
throw new IllegalArgumentException(
"a page above 200 rows makes one query a bulk export; if that is the intent it belongs"
+ " on an endpoint that was designed for it");
}
}
/** Whether a requested page size is permitted. */
public boolean permits(int requested) {
return requested >= 1 && requested <= maxPageSize;
}
}
@@ -0,0 +1,57 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.springdata;
import java.util.Objects;
import java.util.Set;
/**
* Which projection types a coordinate may return.
*
* <p>The rule that keeps this compatibility path from becoming a persistence-model export. Spring
* Data's integration will happily return the entity, and an entity returned from GraphQL is the
* database schema published as an API — every column, every relation the fetch plan happens to
* traverse, and a rename in the database becoming a breaking change for every client.
*
* <p>So the entity type itself is refused by name, and only an approved projection is allowed.
*/
public final class GraphQlRepositoryProjectionPolicy {
private final Set<String> approvedProjections;
private final Set<String> forbiddenEntityTypes;
/**
* @param approvedProjections the projection interfaces or records that may be returned
* @param forbiddenEntityTypes the persistence types that may never be
*/
public GraphQlRepositoryProjectionPolicy(
Set<String> approvedProjections, Set<String> forbiddenEntityTypes) {
this.approvedProjections =
Set.copyOf(Objects.requireNonNull(approvedProjections, "approvedProjections"));
this.forbiddenEntityTypes =
Set.copyOf(Objects.requireNonNull(forbiddenEntityTypes, "forbiddenEntityTypes"));
if (this.approvedProjections.isEmpty()) {
throw new IllegalArgumentException(
"a coordinate with no approved projection can only return the entity, which is the one"
+ " thing this policy exists to prevent");
}
Set<String> both =
this.approvedProjections.stream()
.filter(this.forbiddenEntityTypes::contains)
.collect(java.util.stream.Collectors.toUnmodifiableSet());
if (!both.isEmpty()) {
throw new IllegalArgumentException(
"a type cannot be both an approved projection and a forbidden entity: " + both);
}
}
/** Whether a return type may be served. */
public boolean permits(String returnTypeName) {
return returnTypeName != null
&& !forbiddenEntityTypes.contains(returnTypeName)
&& approvedProjections.contains(returnTypeName);
}
/** The approved projections, for the startup report. */
public Set<String> approvedProjections() {
return approvedProjections;
}
}
@@ -0,0 +1,95 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.sse;
import dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap.GraphQlAdvancedCapability;
import dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap.GraphQlAdvancedCapabilityDisabledException;
import dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap.GraphQlAdvancedFeatureFlags;
import java.time.Duration;
import java.util.Objects;
/**
* Decides whether the SSE subscription transport may exist, and on what terms.
*
* <p>SSE here is the Distinct Connection variant of the GraphQL-over-HTTP specification: one POST
* per subscription, each holding its own connection for as long as the subscription lives. That is
* the property worth stating at the factory, because it is the one that surprises people — {@code
* graphql-transport-ws} multiplexes many subscriptions onto one socket and this does not, so a
* client with twenty subscriptions holds twenty connections and a browser's six-per-origin limit is
* reached at six.
*
* <p>{@link GraphQlSseConnectionPolicy#connectionsRequiredFor(int)} is the arithmetic; this is
* where the transport is refused if the capability was not named.
*/
public final class GraphQlSseHandlerFactory {
private final GraphQlAdvancedFeatureFlags flags;
private final GraphQlSseProperties properties;
private final GraphQlSseConnectionPolicy policy;
/**
* @param flags which capabilities this deployment named
* @param properties the connection bounds
* @param policy what the transport may carry
*/
public GraphQlSseHandlerFactory(
GraphQlAdvancedFeatureFlags flags,
GraphQlSseProperties properties,
GraphQlSseConnectionPolicy policy) {
this.flags = Objects.requireNonNull(flags, "flags");
this.properties = Objects.requireNonNull(properties, "properties");
this.policy = Objects.requireNonNull(policy, "policy");
}
/** Whether the transport may be installed at all. */
public boolean enabled() {
return flags.isEnabled(GraphQlAdvancedCapability.SSE_SUBSCRIPTION);
}
/**
* The settings a handler is installed with.
*
* @throws GraphQlAdvancedCapabilityDisabledException when the capability was not named
*/
public GraphQlSseHandlerSettings settings() {
if (!enabled()) {
throw new GraphQlAdvancedCapabilityDisabledException(
GraphQlAdvancedCapability.SSE_SUBSCRIPTION.name());
}
return new GraphQlSseHandlerSettings(
properties.heartbeatInterval(),
properties.idleTimeout(),
properties.maximumDuration(),
policy);
}
/**
* How many connections a client running this many subscriptions will hold.
*
* <p>Exposed on the factory so a deployment can answer the question before enabling the transport
* rather than after a support ticket about a browser that stopped loading images.
*/
public int connectionsRequiredFor(int subscriptions) {
return policy.connectionsRequiredFor(subscriptions);
}
/**
* What a handler is configured with.
*
* @param heartbeatInterval how often a keepalive is written
* @param idleTimeout how long a silent connection is held
* @param maximumDuration the ceiling regardless of activity
* @param policy what the transport may carry
*/
public record GraphQlSseHandlerSettings(
Duration heartbeatInterval,
Duration idleTimeout,
Duration maximumDuration,
GraphQlSseConnectionPolicy policy) {
public GraphQlSseHandlerSettings {
Objects.requireNonNull(heartbeatInterval, "heartbeatInterval");
Objects.requireNonNull(idleTimeout, "idleTimeout");
Objects.requireNonNull(maximumDuration, "maximumDuration");
Objects.requireNonNull(policy, "policy");
}
}
}
@@ -0,0 +1,102 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.websocket;
import dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap.GraphQlAdvancedCapability;
import dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap.GraphQlAdvancedCapabilityDisabledException;
import dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap.GraphQlAdvancedFeatureFlags;
import java.time.Duration;
import java.util.Objects;
/**
* Decides whether the {@code graphql-transport-ws} handler may exist, and on what terms.
*
* <p>A factory rather than a bean definition because the decision has three inputs and only one of
* them is a flag: the capability must be enabled, the properties must describe a connection that
* can actually be bounded, and the admission policy must be present. A configuration class that
* checked only the flag would produce a handler with an unbounded connection lifetime whenever a
* deployment forgot the rest, and an unbounded WebSocket is a connection slot held by whoever opens
* it.
*
* <p>The factory produces the settings a handler needs rather than the handler itself. Spring
* GraphQL's own {@code GraphQlWebSocketHandler} is the handler; what this leaf owns is whether it
* runs and what limits it runs under, and building the Spring type here would put a framework
* dependency in a module whose whole job is the decision.
*/
public final class GraphQlWebSocketHandlerFactory {
private final GraphQlAdvancedFeatureFlags flags;
private final GraphQlWebSocketProperties properties;
private final GraphQlWebSocketAdmission admission;
/**
* @param flags which capabilities this deployment named
* @param properties the connection bounds
* @param admission who may open a connection
*/
public GraphQlWebSocketHandlerFactory(
GraphQlAdvancedFeatureFlags flags,
GraphQlWebSocketProperties properties,
GraphQlWebSocketAdmission admission) {
this.flags = Objects.requireNonNull(flags, "flags");
this.properties = Objects.requireNonNull(properties, "properties");
this.admission = Objects.requireNonNull(admission, "admission");
}
/** Whether the transport may be installed at all. */
public boolean enabled() {
return flags.isEnabled(GraphQlAdvancedCapability.WEBSOCKET_SUBSCRIPTION);
}
/**
* The settings a handler is installed with.
*
* @throws GraphQlAdvancedCapabilityDisabledException when the capability was not named
*/
public GraphQlWebSocketHandlerSettings settings() {
if (!enabled()) {
// Refused rather than returning a disabled handler. A handler that exists and rejects every
// frame is an endpoint that answers, which is how a client discovers the transport is
// "supported" and writes code against it.
throw new GraphQlAdvancedCapabilityDisabledException(
GraphQlAdvancedCapability.WEBSOCKET_SUBSCRIPTION.name());
}
return new GraphQlWebSocketHandlerSettings(
properties.connectionInitTimeout(),
properties.idleTimeout(),
properties.maximumConnectionAge(),
properties.maximumSubscriptionsPerConnection(),
properties.heartbeatInterval(),
admission);
}
/**
* What a handler is configured with.
*
* @param connectionInitTimeout how long an unacknowledged connection is held
* @param idleTimeout how long a silent connection is held
* @param maximumConnectionAge the ceiling regardless of activity
* @param maximumSubscriptionsPerConnection how many operations one socket may run
* @param heartbeatInterval how often a keepalive is written
* @param admission who may open a connection
*/
public record GraphQlWebSocketHandlerSettings(
Duration connectionInitTimeout,
Duration idleTimeout,
Duration maximumConnectionAge,
int maximumSubscriptionsPerConnection,
Duration heartbeatInterval,
GraphQlWebSocketAdmission admission) {
public GraphQlWebSocketHandlerSettings {
Objects.requireNonNull(connectionInitTimeout, "connectionInitTimeout");
Objects.requireNonNull(idleTimeout, "idleTimeout");
Objects.requireNonNull(maximumConnectionAge, "maximumConnectionAge");
Objects.requireNonNull(heartbeatInterval, "heartbeatInterval");
Objects.requireNonNull(admission, "admission");
if (heartbeatInterval.compareTo(idleTimeout) >= 0) {
throw new IllegalArgumentException(
"a heartbeat at or beyond the idle timeout makes the server time out its own healthy"
+ " connections between beats");
}
}
}
}
@@ -67,8 +67,8 @@ public final class GraphQlSchemaComparator {
// an extension contributed — and cannot see it disappear either. Every schema that composes
// from several files is exactly this shape, which made the omission a breaking change the gate
// reported as no change at all.
Map<String, TypeDefinition> previousTypes = withExtensions(previous);
Map<String, TypeDefinition> candidateTypes = withExtensions(candidate);
Map<String, TypeDefinition<?>> previousTypes = withExtensions(previous);
Map<String, TypeDefinition<?>> candidateTypes = withExtensions(candidate);
compareTypePresence(previousTypes, candidateTypes, changes);
compareTypeKinds(previousTypes, candidateTypes, changes);
@@ -89,14 +89,16 @@ public final class GraphQlSchemaComparator {
* <p>The merged form is what a client sees: the engine builds one type out of the base
* declaration and every extension of it, and a field's origin is invisible on the wire.
*/
private static Map<String, TypeDefinition> withExtensions(TypeDefinitionRegistry registry) {
Map<String, TypeDefinition> merged = new LinkedHashMap<>();
private static Map<String, TypeDefinition<?>> withExtensions(TypeDefinitionRegistry registry) {
Map<String, TypeDefinition<?>> merged = new LinkedHashMap<>();
registry.types().forEach((name, type) -> merged.put(name, mergeExtensions(registry, type)));
return merged;
}
private static TypeDefinition mergeExtensions(
TypeDefinitionRegistry registry, TypeDefinition type) {
// graphql.language.Type stays raw here; see the note on typeNames.
@SuppressWarnings("rawtypes")
private static TypeDefinition<?> mergeExtensions(
TypeDefinitionRegistry registry, TypeDefinition<?> type) {
String name = type.getName();
if (type instanceof ObjectTypeDefinition object) {
@@ -187,8 +189,8 @@ public final class GraphQlSchemaComparator {
}
/** The merged definitions of one kind. */
private static <T extends TypeDefinition> Map<String, T> typesOf(
Map<String, TypeDefinition> types, Class<T> kind) {
private static <T extends TypeDefinition<?>> Map<String, T> typesOf(
Map<String, TypeDefinition<?>> types, Class<T> kind) {
Map<String, T> selected = new LinkedHashMap<>();
types.forEach(
(name, type) -> {
@@ -200,8 +202,8 @@ public final class GraphQlSchemaComparator {
}
private static void compareTypePresence(
Map<String, TypeDefinition> previous,
Map<String, TypeDefinition> candidate,
Map<String, TypeDefinition<?>> previous,
Map<String, TypeDefinition<?>> candidate,
List<GraphQlSchemaChange> changes) {
Set<String> previousTypes = new TreeSet<>(previous.keySet());
@@ -223,12 +225,12 @@ public final class GraphQlSchemaComparator {
* to another, or as nothing at all.
*/
private static void compareTypeKinds(
Map<String, TypeDefinition> previousTypes,
Map<String, TypeDefinition> candidateTypes,
Map<String, TypeDefinition<?>> previousTypes,
Map<String, TypeDefinition<?>> candidateTypes,
List<GraphQlSchemaChange> changes) {
for (String name : new TreeSet<>(previousTypes.keySet())) {
TypeDefinition after = candidateTypes.get(name);
TypeDefinition<?> after = candidateTypes.get(name);
if (after == null) {
continue;
}
@@ -246,13 +248,13 @@ public final class GraphQlSchemaComparator {
* definition untouched.
*/
private static void compareAppliedDirectives(
Map<String, TypeDefinition> previousTypes,
Map<String, TypeDefinition> candidateTypes,
Map<String, TypeDefinition<?>> previousTypes,
Map<String, TypeDefinition<?>> candidateTypes,
List<GraphQlSchemaChange> changes) {
for (String name : new TreeSet<>(previousTypes.keySet())) {
TypeDefinition before = previousTypes.get(name);
TypeDefinition after = candidateTypes.get(name);
TypeDefinition<?> before = previousTypes.get(name);
TypeDefinition<?> after = candidateTypes.get(name);
if (after == null || !before.getClass().equals(after.getClass())) {
continue;
}
@@ -327,8 +329,8 @@ public final class GraphQlSchemaComparator {
}
private static void compareOutputTypes(
Map<String, TypeDefinition> previous,
Map<String, TypeDefinition> candidate,
Map<String, TypeDefinition<?>> previous,
Map<String, TypeDefinition<?>> candidate,
List<GraphQlSchemaChange> changes) {
Map<String, ImplementingTypeDefinition<?>> previousTypes = implementingTypes(previous);
@@ -382,8 +384,10 @@ public final class GraphQlSchemaComparator {
: GraphQlChangeKind.OUTPUT_FIELD_ADDED_NULLABLE)));
}
// graphql.language.Type stays raw here; see the note on typeNames.
@SuppressWarnings("rawtypes")
private static void compareOutputFieldType(
String coordinate, Type<?> before, Type<?> after, List<GraphQlSchemaChange> changes) {
String coordinate, Type before, Type after, List<GraphQlSchemaChange> changes) {
if (sameType(before, after)) {
return;
@@ -474,8 +478,8 @@ public final class GraphQlSchemaComparator {
}
private static void compareInputTypes(
Map<String, TypeDefinition> previous,
Map<String, TypeDefinition> candidate,
Map<String, TypeDefinition<?>> previous,
Map<String, TypeDefinition<?>> candidate,
List<GraphQlSchemaChange> changes) {
Map<String, InputObjectTypeDefinition> previousTypes =
@@ -527,10 +531,12 @@ public final class GraphQlSchemaComparator {
}
}
// graphql.language.Type stays raw here; see the note on typeNames.
@SuppressWarnings("rawtypes")
private static void compareInputValueType(
String coordinate,
Type<?> before,
Type<?> after,
Type before,
Type after,
GraphQlChangeKind strengthened,
GraphQlChangeKind relaxed,
GraphQlChangeKind changed,
@@ -551,8 +557,8 @@ public final class GraphQlSchemaComparator {
}
private static void compareEnums(
Map<String, TypeDefinition> previous,
Map<String, TypeDefinition> candidate,
Map<String, TypeDefinition<?>> previous,
Map<String, TypeDefinition<?>> candidate,
List<GraphQlSchemaChange> changes) {
Map<String, EnumTypeDefinition> previousTypes = typesOf(previous, EnumTypeDefinition.class);
@@ -586,8 +592,8 @@ public final class GraphQlSchemaComparator {
}
private static void compareUnions(
Map<String, TypeDefinition> previous,
Map<String, TypeDefinition> candidate,
Map<String, TypeDefinition<?>> previous,
Map<String, TypeDefinition<?>> candidate,
List<GraphQlSchemaChange> changes) {
Map<String, UnionTypeDefinition> previousTypes = typesOf(previous, UnionTypeDefinition.class);
@@ -677,7 +683,7 @@ public final class GraphQlSchemaComparator {
}
private static Map<String, ImplementingTypeDefinition<?>> implementingTypes(
Map<String, TypeDefinition> merged) {
Map<String, TypeDefinition<?>> merged) {
Map<String, ImplementingTypeDefinition<?>> types = new LinkedHashMap<>();
typesOf(merged, ObjectTypeDefinition.class).forEach(types::put);
typesOf(merged, InterfaceTypeDefinition.class).forEach(types::put);
@@ -720,6 +726,26 @@ public final class GraphQlSchemaComparator {
.collect(Collectors.toCollection(LinkedHashSet::new));
}
/**
* The names of a type reference list, taken exactly as graphql-java hands it over.
*
* <p>Every {@code TypeDefinition} in this class is parameterized. {@code graphql.language.Type}
* is not, and cannot be: graphql-java declares {@code Type<T extends Type>} — its own bound is
* raw — and its collections are raw on both sides. {@code getImplements()} and {@code
* getMemberTypes()} return {@code List<Type>}, which is not assignable to {@code List<? extends
* Type<?>>} because a raw element type is not a subtype of {@code Type<?>} in an argument
* position; and {@code UnionTypeDefinition.Builder.memberTypes} takes {@code List<Type>} back, so
* a parameterized local cannot be handed to it either. Reading and writing are both raw.
*
* <p>So the rawness is admitted at the six members that touch {@code Type} rather than hidden
* behind defensive copies at every boundary. A copy would imply this class needed one, and the
* suppression is deliberately per-member rather than on the class so that a raw type introduced
* anywhere else here still surfaces.
*
* <p>The build does not enable {@code -Xlint:rawtypes}, so javac is silent either way; this
* exists for the editor's compiler, which is not silent and should not be made silent globally.
*/
@SuppressWarnings("rawtypes")
private static Set<String> typeNames(List<Type> types) {
return types.stream()
.map(GraphQlSchemaComparator::print)
@@ -730,15 +756,21 @@ public final class GraphQlSchemaComparator {
return nonNull(definition.getType()) && definition.getDefaultValue() == null;
}
private static boolean nonNull(Type<?> type) {
// graphql.language.Type stays raw here; see the note on typeNames.
@SuppressWarnings("rawtypes")
private static boolean nonNull(Type type) {
return type instanceof NonNullType;
}
private static Type<?> unwrap(Type<?> type) {
// graphql.language.Type stays raw here; see the note on typeNames.
@SuppressWarnings("rawtypes")
private static Type unwrap(Type type) {
return type instanceof NonNullType nonNullType ? nonNullType.getType() : type;
}
private static boolean sameType(Type<?> left, Type<?> right) {
// graphql.language.Type stays raw here; see the note on typeNames.
@SuppressWarnings("rawtypes")
private static boolean sameType(Type left, Type right) {
return print(left).equals(print(right));
}
@@ -747,7 +779,7 @@ public final class GraphQlSchemaComparator {
}
/** Names every definition kind the comparator understands, for coverage reporting. */
public static Set<Class<? extends TypeDefinition>> comparedDefinitionKinds() {
public static Set<Class<? extends TypeDefinition<?>>> comparedDefinitionKinds() {
return Set.of(
ObjectTypeDefinition.class,
InterfaceTypeDefinition.class,
@@ -88,6 +88,16 @@ public enum GraphQlAdvancedModule {
"security"),
/** The GraphQL over WebSocket protocol state machine. */
/**
* The allowlisted Spring Data compatibility path.
*
* <p>Pure policy and no edge to persistence, which is the point: this module decides which
* repository exposures a deployment published, and it decides that without being able to name a
* repository, an entity or a Spring Data type. Everything it works with is a name the deployment
* registered.
*/
SPRING_DATA("advanced.springdata", "advanced.springdata"),
WEBSOCKET("advanced.websocket", "advanced.websocket", "advanced.bootstrap");
private final String id;
@@ -0,0 +1,169 @@
package dev.caskeleton.adapter.inbound.graphql.advanced;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap.GraphQlAdvancedCapability;
import dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap.GraphQlAdvancedCapabilityDisabledException;
import dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap.GraphQlAdvancedFeatureFlags;
import dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap.GraphQlAdvancedModuleGuard;
import dev.caskeleton.adapter.inbound.graphql.advanced.rsocket.GraphQlRSocketHandlerFactory;
import dev.caskeleton.adapter.inbound.graphql.advanced.rsocket.GraphQlRSocketProperties;
import dev.caskeleton.adapter.inbound.graphql.advanced.rsocket.GraphQlRSocketRoutePolicy;
import dev.caskeleton.adapter.inbound.graphql.advanced.sse.GraphQlSseConnectionPolicy;
import dev.caskeleton.adapter.inbound.graphql.advanced.sse.GraphQlSseHandlerFactory;
import dev.caskeleton.adapter.inbound.graphql.advanced.sse.GraphQlSseProperties;
import dev.caskeleton.adapter.inbound.graphql.advanced.websocket.GraphQlWebSocketAdmission;
import dev.caskeleton.adapter.inbound.graphql.advanced.websocket.GraphQlWebSocketHandlerFactory;
import dev.caskeleton.adapter.inbound.graphql.advanced.websocket.GraphQlWebSocketProperties;
import java.time.Clock;
import java.time.Duration;
import java.time.ZoneOffset;
import java.util.Set;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
/**
* Whether each transport may exist at all, and what it is installed with.
*
* <p>The three factories are the seam between "a deployment named this capability" and "a handler
* is serving requests". The seam matters because the alternative — a handler that is always
* installed and rejects when disabled — is an endpoint that answers, and an endpoint that answers
* is one a client writes code against.
*/
class TransportHandlerFactoryTest {
private static final Clock CLOCK =
Clock.fixed(java.time.Instant.parse("2026-08-25T10:00:00Z"), ZoneOffset.UTC);
private static GraphQlWebSocketAdmission admission(GraphQlAdvancedFeatureFlags flags) {
return new GraphQlWebSocketAdmission(
new GraphQlAdvancedModuleGuard(flags), GraphQlWebSocketProperties.defaults(), CLOCK);
}
private static GraphQlWebSocketHandlerFactory websocket(GraphQlAdvancedFeatureFlags flags) {
return new GraphQlWebSocketHandlerFactory(
flags, GraphQlWebSocketProperties.defaults(), admission(flags));
}
private static GraphQlSseHandlerFactory sse(GraphQlAdvancedFeatureFlags flags) {
return new GraphQlSseHandlerFactory(
flags, GraphQlSseProperties.defaults(), GraphQlSseConnectionPolicy.standard());
}
private static GraphQlRSocketHandlerFactory rsocket(
GraphQlAdvancedFeatureFlags flags, boolean transportEnabled) {
return new GraphQlRSocketHandlerFactory(
flags,
new GraphQlRSocketProperties(
transportEnabled,
Set.of("graphql"),
Set.of("message/x.rsocket.routing.v0"),
transportEnabled ? Set.of("internal-gateway") : Set.of()),
new GraphQlRSocketRoutePolicy(Set.of("graphql")));
}
@Test
@DisplayName("no transport exists until its capability is named")
void noTransportExistsUntilNamed() {
GraphQlAdvancedFeatureFlags off = GraphQlAdvancedFeatureFlags.disabled();
assertThat(websocket(off).enabled()).isFalse();
assertThat(sse(off).enabled()).isFalse();
assertThat(rsocket(off, false).enabled()).isFalse();
}
@Test
@DisplayName("asking a disabled transport for its settings is refused, not answered emptily")
void disabledTransportRefusesRatherThanAnswering() {
// A handler that exists and rejects every request is an endpoint that answers, which is how a
// client discovers the transport is "supported".
GraphQlAdvancedFeatureFlags off = GraphQlAdvancedFeatureFlags.disabled();
assertThatThrownBy(() -> websocket(off).settings())
.isInstanceOf(GraphQlAdvancedCapabilityDisabledException.class);
assertThatThrownBy(() -> sse(off).settings())
.isInstanceOf(GraphQlAdvancedCapabilityDisabledException.class);
assertThatThrownBy(() -> rsocket(off, false).settings())
.isInstanceOf(GraphQlAdvancedCapabilityDisabledException.class);
}
@Test
@DisplayName("a named WebSocket capability produces bounded settings")
void websocketSettingsAreBounded() {
GraphQlWebSocketHandlerFactory.GraphQlWebSocketHandlerSettings settings =
websocket(
GraphQlAdvancedFeatureFlags.enabling(
GraphQlAdvancedCapability.WEBSOCKET_SUBSCRIPTION))
.settings();
assertThat(settings.connectionInitTimeout()).isPositive();
assertThat(settings.maximumConnectionAge()).isPositive();
assertThat(settings.maximumSubscriptionsPerConnection()).isPositive();
assertThat(settings.heartbeatInterval()).isLessThan(settings.idleTimeout());
}
@Test
@DisplayName("a heartbeat at or beyond the idle timeout is refused")
void selfDefeatingHeartbeatIsRefused() {
// The server would time out its own healthy connections between beats.
assertThatThrownBy(
() ->
new GraphQlWebSocketHandlerFactory.GraphQlWebSocketHandlerSettings(
Duration.ofSeconds(10),
Duration.ofSeconds(30),
Duration.ofHours(1),
10,
Duration.ofSeconds(30),
admission(GraphQlAdvancedFeatureFlags.disabled())))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("time out its own healthy connections");
}
@Test
@DisplayName("SSE says how many connections a client will hold before it is enabled")
void sseReportsItsConnectionCost() {
// Distinct Connection: one POST per subscription. A client with twenty subscriptions holds
// twenty connections, and a browser's six-per-origin limit is reached at six.
GraphQlSseHandlerFactory factory =
sse(GraphQlAdvancedFeatureFlags.enabling(GraphQlAdvancedCapability.SSE_SUBSCRIPTION));
assertThat(factory.connectionsRequiredFor(20)).isEqualTo(20);
assertThat(factory.settings().heartbeatInterval()).isLessThan(factory.settings().idleTimeout());
}
@Test
@DisplayName("RSocket needs the transport switch as well as the capability")
void rsocketNeedsBothGates() {
// Two acts rather than one. A flag is set by whoever edits configuration; enabling an
// experimental transport should not be reachable the same way a supported one is.
GraphQlAdvancedFeatureFlags named =
GraphQlAdvancedFeatureFlags.enabling(GraphQlAdvancedCapability.RSOCKET);
assertThat(rsocket(named, false).enabled()).isFalse();
assertThat(rsocket(named, true).enabled()).isTrue();
}
@Test
@DisplayName("an experimental transport with no named consumer is refused")
void unnamedConsumerIsRefused() {
// One nobody will notice breaking, which is the state it must not reach.
assertThatThrownBy(
() ->
new GraphQlRSocketHandlerFactory.GraphQlRSocketHandlerSettings(
new GraphQlRSocketRoutePolicy(Set.of("graphql")), Set.of(), Set.of()))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("nobody will notice breaking");
}
@Test
@DisplayName("enabling one transport does not enable another")
void capabilitiesAreIndependent() {
GraphQlAdvancedFeatureFlags onlyWebSocket =
GraphQlAdvancedFeatureFlags.enabling(GraphQlAdvancedCapability.WEBSOCKET_SUBSCRIPTION);
assertThat(websocket(onlyWebSocket).enabled()).isTrue();
assertThat(sse(onlyWebSocket).enabled()).isFalse();
assertThat(rsocket(onlyWebSocket, true).enabled()).isFalse();
}
}
@@ -0,0 +1,214 @@
package dev.caskeleton.adapter.inbound.graphql.advanced.springdata;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.util.Set;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
/**
* The allowlist that stops a persistence annotation from publishing an API.
*
* <p>Spring's {@code @GraphQlRepository} registers a data fetcher for every annotated repository it
* finds. Without this, the set of exposed fields is whatever somebody annotated — a decision made
* in a persistence class, by whoever was working on persistence, and reviewed as a persistence
* change.
*/
class GraphQlRepositoryExposureValidatorTest {
private static final GraphQlRepositoryExposure ORDERS =
new GraphQlRepositoryExposure("OrderRepository", "Query.orders");
private static GraphQlRepositoryAllowlist allowlistWith(
GraphQlRepositoryArgumentPolicy arguments,
GraphQlRepositoryPaginationPolicy pagination,
GraphQlRepositoryProjectionPolicy projection) {
return GraphQlRepositoryAllowlist.builder()
.expose(ORDERS, arguments, pagination, projection)
.build();
}
private static GraphQlRepositoryProjectionPolicy projection() {
return new GraphQlRepositoryProjectionPolicy(
Set.of("OrderSummaryProjection"), Set.of("OrderEntity"));
}
@Test
@DisplayName("an unregistered repository is refused")
void unregisteredRepositoryIsRejected() {
GraphQlRepositoryExposureValidator validator =
new GraphQlRepositoryExposureValidator(GraphQlRepositoryAllowlist.empty());
assertThatThrownBy(() -> validator.verify(ORDERS))
.isInstanceOf(GraphQlRepositoryExposureRejectedException.class)
.hasMessageContaining("not by an annotation on a persistence class");
}
@Test
@DisplayName("the same repository at an unregistered coordinate is still refused")
void coordinateIsPartOfTheDecision() {
// Spring binds a repository to whatever coordinate its return type matches, so the same
// repository reached from two fields is two exposures with two audiences.
GraphQlRepositoryExposureValidator validator =
new GraphQlRepositoryExposureValidator(
allowlistWith(
GraphQlRepositoryArgumentPolicy.none(),
new GraphQlRepositoryPaginationPolicy(true, 50),
projection()));
assertThat(validator.verify(ORDERS)).isNotNull();
assertThatThrownBy(
() ->
validator.verify(
new GraphQlRepositoryExposure("OrderRepository", "Query.allOrders")))
.isInstanceOf(GraphQlRepositoryExposureRejectedException.class);
}
@Test
@DisplayName("returning the entity is refused, however it is reached")
void entityReturnIsRefused() {
// The rule that keeps this compatibility path from becoming a database export: every column
// published, and a rename in the database a breaking change for every client.
GraphQlRepositoryExposureValidator validator =
new GraphQlRepositoryExposureValidator(
allowlistWith(
GraphQlRepositoryArgumentPolicy.none(),
new GraphQlRepositoryPaginationPolicy(true, 50),
projection()));
assertThatThrownBy(() -> validator.verifyRequest(ORDERS, Set.of(), Set.of(), 10, "OrderEntity"))
.isInstanceOf(GraphQlRepositoryExposureRejectedException.class)
.hasMessageContaining("publishes the persistence model as an API");
validator.verifyRequest(ORDERS, Set.of(), Set.of(), 10, "OrderSummaryProjection");
}
@Test
@DisplayName("a filter the coordinate did not register is refused")
void unregisteredFilterIsRefused() {
// A query-by-example fetcher derives its predicate from whatever arrives, and the binder
// resolves against the entity — so an un-allowlisted argument filters on columns the schema
// never published.
GraphQlRepositoryExposureValidator validator =
new GraphQlRepositoryExposureValidator(
allowlistWith(
new GraphQlRepositoryArgumentPolicy(Set.of("status"), Set.of("placedAt")),
new GraphQlRepositoryPaginationPolicy(true, 50),
projection()));
validator.verifyRequest(
ORDERS, Set.of("status"), Set.of("placedAt"), 10, "OrderSummaryProjection");
assertThatThrownBy(
() ->
validator.verifyRequest(
ORDERS, Set.of("internalCostBasis"), Set.of(), 10, "OrderSummaryProjection"))
.isInstanceOf(GraphQlRepositoryExposureRejectedException.class)
.hasMessageContaining("filter:internalCostBasis");
}
@Test
@DisplayName("filter and sort are allowlisted separately")
void filterAndSortAreSeparate() {
// They read alike and behave differently under load: a filter on an unindexed column is one
// slow query, a sort on one is a full table sort on every page.
GraphQlRepositoryExposureValidator validator =
new GraphQlRepositoryExposureValidator(
allowlistWith(
new GraphQlRepositoryArgumentPolicy(Set.of("status"), Set.of()),
new GraphQlRepositoryPaginationPolicy(true, 50),
projection()));
assertThatThrownBy(
() ->
validator.verifyRequest(
ORDERS, Set.of("status"), Set.of("status"), 10, "OrderSummaryProjection"))
.isInstanceOf(GraphQlRepositoryExposureRejectedException.class)
.hasMessageContaining("sort:status");
}
@Test
@DisplayName("every violation is reported at once")
void violationsAreReportedTogether() {
GraphQlRepositoryExposureValidator validator =
new GraphQlRepositoryExposureValidator(
allowlistWith(
GraphQlRepositoryArgumentPolicy.none(),
new GraphQlRepositoryPaginationPolicy(true, 20),
projection()));
assertThatThrownBy(
() -> validator.verifyRequest(ORDERS, Set.of("a"), Set.of("b"), 500, "OrderEntity"))
.isInstanceOf(GraphQlRepositoryExposureRejectedException.class)
.hasMessageContaining("filter:a")
.hasMessageContaining("sort:b")
.hasMessageContaining("page size 500")
.hasMessageContaining("not an approved projection");
}
@Test
@DisplayName("a page size beyond the coordinate's limit is refused")
void oversizedPageIsRefused() {
GraphQlRepositoryPaginationPolicy policy = new GraphQlRepositoryPaginationPolicy(true, 50);
assertThat(policy.permits(50)).isTrue();
assertThat(policy.permits(51)).isFalse();
assertThat(policy.permits(0)).isFalse();
}
@Test
@DisplayName("a page size that turns a query into a bulk export is refused at configuration")
void bulkExportPageSizeIsRefused() {
assertThatThrownBy(() -> new GraphQlRepositoryPaginationPolicy(true, 5_000))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("designed for it");
}
@Test
@DisplayName("a coordinate with no approved projection is refused at configuration")
void projectionlessCoordinateIsRefused() {
// It could only return the entity, which is the one thing the policy exists to prevent.
assertThatThrownBy(() -> new GraphQlRepositoryProjectionPolicy(Set.of(), Set.of("OrderEntity")))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("this policy exists to prevent");
}
@Test
@DisplayName("two registrations for one coordinate are refused")
void duplicateRegistrationIsRefused() {
assertThatThrownBy(
() ->
GraphQlRepositoryAllowlist.builder()
.expose(
ORDERS,
GraphQlRepositoryArgumentPolicy.none(),
new GraphQlRepositoryPaginationPolicy(true, 20),
projection())
.expose(
ORDERS,
GraphQlRepositoryArgumentPolicy.none(),
new GraphQlRepositoryPaginationPolicy(true, 200),
projection()))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("silently decide the coordinate's limits");
}
@Test
@DisplayName("nothing is exposed until a deployment says so")
void nothingIsExposedByDefault() {
GraphQlRepositoryAllowlist empty = GraphQlRepositoryAllowlist.empty();
assertThat(empty.size()).isZero();
assertThat(empty.contains("OrderRepository")).isFalse();
assertThat(new GraphQlRepositoryExposureValidator(empty).anyExposure()).isFalse();
}
@Test
@DisplayName("an exposure names a repository and a Type.field coordinate")
void exposureShapeIsChecked() {
assertThatThrownBy(() -> new GraphQlRepositoryExposure("OrderRepository", "orders"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Type.field");
assertThatThrownBy(() -> new GraphQlRepositoryExposure(" ", "Query.orders"))
.isInstanceOf(IllegalArgumentException.class);
}
}
+67 -69
View File
@@ -2,14 +2,13 @@
# Manual edits can break the build and are not advised.
# This file is expected to be part of source control.
biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,testCompileClasspath
ch.qos.logback:logback-classic:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
ch.qos.logback:logback-core:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson.core:jackson-annotations:2.20=testCompileClasspath,testRuntimeClasspath
com.fasterxml:classmate:1.7.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor
ch.qos.logback:logback-classic:1.5.38=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
ch.qos.logback:logback-core:1.5.38=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson.core:jackson-annotations:2.21=testCompileClasspath,testRuntimeClasspath
com.fasterxml:classmate:1.7.3=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,testAnnotationProcessor
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor
com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs
com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,testCompileClasspath
com.github.spotbugs:spotbugs:4.10.2=spotbugs
com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs
com.google.android:annotations:4.1.1.4=runtimeClasspath,testRuntimeClasspath
@@ -40,13 +39,13 @@ com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnno
com.google.protobuf:protobuf-java-util:3.25.5=runtimeClasspath,testRuntimeClasspath
com.google.protobuf:protobuf-java:3.25.5=annotationProcessor,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins
com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath
com.jayway.jsonpath:json-path:2.10.0=testCompileClasspath,testRuntimeClasspath
com.puppycrawl.tools:checkstyle:13.5.0=checkstyle
com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath
commons-beanutils:commons-beanutils:1.11.0=checkstyle
commons-collections:commons-collections:3.2.2=checkstyle
commons-io:commons-io:2.21.0=spotbugs
commons-logging:commons-logging:1.3.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
commons-logging:commons-logging:1.3.6=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
info.picocli:picocli:4.7.7=checkstyle
io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor
@@ -59,15 +58,15 @@ io.grpc:grpc-protobuf:1.68.1=runtimeClasspath,testCompileClasspath,testRuntimeCl
io.grpc:grpc-services:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.grpc:grpc-stub:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.grpc:grpc-util:1.68.1=runtimeClasspath,testRuntimeClasspath
io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-commons:1.16.7=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-observation:1.16.7=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.perfmark:perfmark-api:0.27.0=runtimeClasspath,testRuntimeClasspath
jakarta.activation:jakarta.activation-api:2.1.4=testCompileClasspath,testRuntimeClasspath
jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.validation:jakarta.validation-api:3.1.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=testCompileClasspath,testRuntimeClasspath
jakarta.xml.bind:jakarta.xml.bind-api:4.0.5=testCompileClasspath,testRuntimeClasspath
javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor
jaxen:jaxen:2.0.0=spotbugs
jaxen:jaxen:2.0.6=spotbugs
net.bytebuddy:byte-buddy-agent:1.17.8=testCompileClasspath,testRuntimeClasspath
net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath
net.minidev:accessors-smart:2.6.0=testCompileClasspath,testRuntimeClasspath
@@ -80,19 +79,19 @@ org.apache.commons:commons-text:1.15.0=spotbugs
org.apache.commons:commons-text:1.3=checkstyle
org.apache.httpcomponents:httpclient:4.5.13=checkstyle
org.apache.httpcomponents:httpcore:4.4.16=checkstyle
org.apache.logging.log4j:log4j-api:2.25.2=compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
org.apache.logging.log4j:log4j-core:2.25.2=spotbugs
org.apache.logging.log4j:log4j-to-slf4j:2.25.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.logging.log4j:log4j-api:2.25.5=compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
org.apache.logging.log4j:log4j-core:2.25.5=spotbugs
org.apache.logging.log4j:log4j-to-slf4j:2.25.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.maven.doxia:doxia-core:1.12.0=checkstyle
org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle
org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle
org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle
org.apache.tomcat.embed:tomcat-embed-core:11.0.14=testCompileClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-el:11.0.14=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=testCompileClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-core:11.0.24=testCompileClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-el:11.0.24=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-websocket:11.0.24=testCompileClasspath,testRuntimeClasspath
org.apache.xbean:xbean-reflect:3.7=checkstyle
org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath
org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath
org.assertj:assertj-core:3.27.7=testCompileClasspath,testRuntimeClasspath
org.awaitility:awaitility:4.3.0=testCompileClasspath,testRuntimeClasspath
org.checkerframework:checker-qual:3.42.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.codehaus.mojo:animal-sniffer-annotations:1.24=runtimeClasspath,testRuntimeClasspath
@@ -104,16 +103,16 @@ org.dom4j:dom4j:2.2.0=spotbugs
org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath
org.hibernate.validator:hibernate-validator:9.0.1.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.javassist:javassist:3.28.0-GA=checkstyle
org.jboss.logging:jboss-logging:3.6.1.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath
org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath
org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath
org.junit.platform:junit-platform-engine:6.0.1=testRuntimeClasspath
org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath
org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath
org.jboss.logging:jboss-logging:3.6.3.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.jspecify:jspecify:1.0.1=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-api:6.0.3=testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-engine:6.0.3=testRuntimeClasspath
org.junit.jupiter:junit-jupiter-params:6.0.3=testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter:6.0.3=testCompileClasspath,testRuntimeClasspath
org.junit.platform:junit-platform-commons:6.0.3=testCompileClasspath,testRuntimeClasspath
org.junit.platform:junit-platform-engine:6.0.3=testRuntimeClasspath
org.junit.platform:junit-platform-launcher:6.0.3=testRuntimeClasspath
org.junit:junit-bom:6.0.3=testCompileClasspath,testRuntimeClasspath
org.junit:junit-bom:6.1.0=spotbugs
org.mockito:mockito-core:5.20.0=mockitoAgent,testCompileClasspath,testRuntimeClasspath
org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath
@@ -132,47 +131,46 @@ org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath
org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor
org.reflections:reflections:0.10.2=checkstyle
org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath
org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath
org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j
org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-configuration-processor:4.0.0=annotationProcessor
org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-http-converter:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-restclient:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-servlet:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-validation:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-validation:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-web-server:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-aop:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-beans:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-context:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-core:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-expression:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-test:7.0.1=testCompileClasspath,testRuntimeClasspath
org.springframework:spring-web:7.0.1=testCompileClasspath,testRuntimeClasspath
org.springframework:spring-webmvc:7.0.1=testCompileClasspath,testRuntimeClasspath
org.slf4j:jul-to-slf4j:2.0.18=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.slf4j:slf4j-api:2.0.18=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath
org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j
org.slf4j:slf4j-simple:2.0.18=checkstyle
org.springframework.boot:spring-boot-autoconfigure:4.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-configuration-processor:4.0.8=annotationProcessor
org.springframework.boot:spring-boot-http-converter:4.0.8=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-jackson:4.0.8=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-resttestclient:4.0.8=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-servlet:4.0.8=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson-test:4.0.8=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson:4.0.8=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-logging:4.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-test:4.0.8=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.8=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-tomcat:4.0.8=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-validation:4.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-webmvc-test:4.0.8=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-webmvc:4.0.8=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter:4.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-test-autoconfigure:4.0.8=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-test:4.0.8=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-tomcat:4.0.8=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-validation:4.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-web-server:4.0.8=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-webmvc-test:4.0.8=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-webmvc:4.0.8=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot:4.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-aop:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-beans:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-context:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-core:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-expression:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-test:7.0.9=testCompileClasspath,testRuntimeClasspath
org.springframework:spring-web:7.0.9=testCompileClasspath,testRuntimeClasspath
org.springframework:spring-webmvc:7.0.9=testCompileClasspath,testRuntimeClasspath
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath
org.yaml:snakeyaml:2.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson.core:jackson-core:3.0.2=testCompileClasspath,testRuntimeClasspath
tools.jackson.core:jackson-databind:3.0.2=testCompileClasspath,testRuntimeClasspath
tools.jackson:jackson-bom:3.0.2=testCompileClasspath,testRuntimeClasspath
tools.jackson.core:jackson-core:3.1.5=testCompileClasspath,testRuntimeClasspath
tools.jackson.core:jackson-databind:3.1.5=testCompileClasspath,testRuntimeClasspath
tools.jackson:jackson-bom:3.1.5=testCompileClasspath,testRuntimeClasspath
empty=
+193
View File
@@ -1,3 +1,9 @@
// The inbound HTTP API execution platform design models itself as 23 Stable Gradle modules under
// modules/web. This repository's fail-closed module registry outranks that layout, so those modules
// are packages here and WebModuleBoundaryTest enforces the design's module dependency table. The
// full mapping, and the three other places the design's assumptions were adapted, are in
// docs/web/repository-adaptation.md.
// HTTP / web adapters. Depends on application and shared operational contracts.
dependencies {
implementation project(':application-core')
@@ -16,6 +22,24 @@ dependencies {
// never a hand-maintained stale schema). The release-blocking drift gate is
// owned by feature-contract-verification-test-suite (planned).
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-api:3.0.0'
// The two Advanced representations, compile-only on purpose. They were `implementation` first,
// on the reasoning that a missing backend would surface as a NoClassDefFoundError at the first
// request that negotiated one. That reasoning was wrong about what the jars do: Spring Boot's
// Jackson auto-configuration registers an `xmlMapper` and a `cborMapper` the moment each
// backend is on the runtime classpath, and Spring registers a message converter for XML with
// it. So every deployment got three ObjectMapper beans — which broke `@Autowired ObjectMapper`
// in the composition root with an ambiguity — and, worse, silently began parsing
// `application/xml` request bodies. An Advanced capability that is off by default had turned
// XML deserialization on for everybody, which is the opposite of what the flag promises and an
// XXE surface nobody chose.
//
// Compile-only keeps the factories compiling and their tests running while leaving the runtime
// classpath to the deployment that enables the capability. `WebRepresentation.available()` is
// what turns the absent backend into a sentence instead of a NoClassDefFoundError.
compileOnly 'tools.jackson.dataformat:jackson-dataformat-cbor'
compileOnly 'tools.jackson.dataformat:jackson-dataformat-xml'
testImplementation 'tools.jackson.dataformat:jackson-dataformat-cbor'
testImplementation 'tools.jackson.dataformat:jackson-dataformat-xml'
// Fileserver reactive transport. Only the WebFlux framework and Reactor core are declared —
// deliberately not spring-boot-starter-webflux, which would put a second embedded server
// (reactor-netty) on the runtime classpath. DispatcherServlet stays present, so Spring Boot's
@@ -30,9 +54,178 @@ dependencies {
tasks.named('test') {
useJUnitPlatform {
excludeTags 'security-boundary'
// The parity gate compares recordings written by three lanes. In `test` alone only one of
// them exists, and a gate that fails because the others have not run yet is a gate people
// learn to ignore. It runs from `webCrossStackParityTest`, which depends on all three.
excludeTags 'web-parity'
}
}
// The web platform's reusable ArchUnit rules ship in their own source set, consumed by this leaf's
// tests and by the composition root. A rule pack that only its own fixture tests import is verified
// as library code and applied to nothing — the shape the JPA testkit had to be corrected out of.
strictTestLanes {
sourceSet('testkit') { compilesAgainst 'main' }
// The Jetty compatibility lane is its own source set because it needs a different embedded
// server on the classpath. Two servers in one source set means Spring Boot picks one and the
// "Jetty" lane silently runs on Tomcat — a compatibility matrix that certifies the same
// container twice.
sourceSet('jettyCompatTest') {
compilesAgainst 'main', 'testkit'
inherits 'implementation'
}
// Reactor Netty is the Stable WebFlux server baseline and it cannot share a source set with
// Tomcat: Spring Boot deduces one application type from the classpath, so with both servers
// present the reactive gate would start a servlet container and certify nothing reactive.
// Nothing is inherited: the leaf's own `implementation` carries spring-boot-starter-web, and
// inheriting it would put Tomcat back on this lane's classpath. Boot then deduces a servlet
// application, starts a servlet container, and the reactive gate certifies the servlet stack.
// The Nginx lane runs the platform behind a real reverse proxy in a container. Its own source
// set because it is the only lane that needs Docker: folding it into `test` would make every
// developer's `check` depend on a container runtime, and the usual outcome of that is an
// @Disabled that nobody notices has been there for months.
sourceSet('nginxProxyTest') {
compilesAgainst 'main', 'testkit'
inherits 'implementation'
}
sourceSet('webfluxContractTest') {
compilesAgainst 'main', 'testkit'
// Inherits nothing. The default is to extend `testImplementation`, which extends the leaf's
// own `implementation` and therefore carries spring-boot-starter-web — and with Tomcat on
// the classpath Boot deduces a servlet application, starts a servlet container, and the
// reactive gate certifies the servlet stack while reporting itself green.
inherits()
}
}
testkitPublisher {
consumedBy 'test'
publishAs 'webTestkit'
}
// ArchUnit is declared after the testkit source set exists, because `testkitImplementation` is
// created by that declaration.
dependencies {
testkitImplementation 'com.tngtech.archunit:archunit-junit5:1.3.0'
testImplementation 'com.tngtech.archunit:archunit-junit5:1.3.0'
// Jetty replaces Tomcat for this lane only. The exclusion is what makes the lane mean
// something: with both on the classpath Boot starts Tomcat and the lane certifies nothing.
jettyCompatTestImplementation('org.springframework.boot:spring-boot-starter-jetty')
jettyCompatTestImplementation('org.springframework.boot:spring-boot-starter-test') {
exclude group: 'org.springframework.boot', module: 'spring-boot-starter-tomcat'
}
jettyCompatTestImplementation('org.springframework.boot:spring-boot-starter-web') {
exclude group: 'org.springframework.boot', module: 'spring-boot-starter-tomcat'
}
jettyCompatTestImplementation 'org.springframework.boot:spring-boot-starter-security'
jettyCompatTestImplementation 'org.springframework.boot:spring-boot-starter-validation'
// The root build adds the launcher to `test` only; a custom lane has to say so itself, or the
// executor starts and finds no JUnit Platform.
jettyCompatTestRuntimeOnly 'org.junit.platform:junit-platform-launcher'
// The reactive lane: reactor-netty in, Tomcat out. The exclusion is what makes the lane mean
// something — Boot picks the servlet stack when both are present.
webfluxContractTestImplementation('org.springframework.boot:spring-boot-starter-webflux') {
exclude group: 'org.springframework.boot', module: 'spring-boot-starter-tomcat'
}
webfluxContractTestImplementation('org.springframework.boot:spring-boot-starter-test') {
exclude group: 'org.springframework.boot', module: 'spring-boot-starter-tomcat'
}
webfluxContractTestImplementation 'org.springframework.boot:spring-boot-starter-validation'
webfluxContractTestImplementation 'io.projectreactor:reactor-test'
// Explicit because this lane inherits nothing: the platform's auto-configuration references
// ObjectMapper, and without Jackson the condition evaluation fails before a server starts.
webfluxContractTestImplementation 'org.springframework.boot:spring-boot-starter-jackson'
webfluxContractTestImplementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310'
webfluxContractTestImplementation project(':application-core')
webfluxContractTestImplementation project(':shared-contract')
webfluxContractTestRuntimeOnly 'org.junit.platform:junit-platform-launcher'
// The Nginx lane. Testcontainers starts the proxy; the application runs in this JVM on a
// random port and the container reaches it through the host gateway.
nginxProxyTestImplementation 'org.springframework.boot:spring-boot-starter-test'
nginxProxyTestImplementation 'org.springframework.boot:spring-boot-starter-web'
nginxProxyTestImplementation 'org.springframework.boot:spring-boot-starter-security'
nginxProxyTestImplementation 'org.springframework.boot:spring-boot-starter-validation'
nginxProxyTestImplementation 'org.testcontainers:testcontainers'
nginxProxyTestImplementation 'org.testcontainers:testcontainers-junit-jupiter'
nginxProxyTestRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
// The lane task. A release compatibility gate that is not wired to a task is a document.
tasks.register('webFluxContractTest', Test) {
group = 'verification'
description = 'Runs the Stable HTTP contract against a real Reactor Netty.'
testClassesDirs = sourceSets.webfluxContractTest.output.classesDirs
classpath = sourceSets.webfluxContractTest.runtimeClasspath
useJUnitPlatform()
failOnNoDiscoveredTests = true
outputs.upToDateWhen { false }
jvmArgs '-Duser.timezone=UTC'
}
// Docker-gated, and it says so rather than skipping. A lane that quietly passes when the container
// runtime is missing is a lane that has been certifying nothing since whenever Docker last broke.
tasks.register('webNginxProxyTest', Test) {
group = 'verification'
description = 'Runs the proxy, prefix and spoofing contract behind a real Nginx.'
testClassesDirs = sourceSets.nginxProxyTest.output.classesDirs
classpath = sourceSets.nginxProxyTest.runtimeClasspath
useJUnitPlatform()
failOnNoDiscoveredTests = true
outputs.upToDateWhen { false }
jvmArgs '-Duser.timezone=UTC'
}
// The cross-stack gate. It depends on every recording lane rather than tolerating a missing one:
// a parity check that compares whatever happens to be present would report agreement across a
// matrix with a hole in it.
tasks.register('webCrossStackParityTest', Test) {
group = 'verification'
description = 'Compares the wire contract recorded by Tomcat, Jetty and Reactor Netty.'
testClassesDirs = sourceSets.test.output.classesDirs
classpath = sourceSets.test.runtimeClasspath
useJUnitPlatform {
includeTags 'web-parity'
}
failOnNoDiscoveredTests = true
outputs.upToDateWhen { false }
jvmArgs '-Duser.timezone=UTC'
dependsOn 'test', 'webJettyCompatTest', 'webFluxContractTest'
}
// The Advanced lane. Every capability is off unless a deployment names it, so none of them is
// exercised by anything a production deployment runs — which makes a lane that runs them all the
// only place a break is noticed before whoever enables it notices.
//
// They also run inside `test`, deliberately. They are ordinary unit tests, and excluding them from
// the PR gate to make this lane look meaningful would mean the PR gate stopped covering a fifth of
// the leaf.
tasks.register('webAdvancedTest', Test) {
group = 'verification'
description = 'Runs every web Advanced capability contract.'
testClassesDirs = sourceSets.test.output.classesDirs
classpath = sourceSets.test.runtimeClasspath
useJUnitPlatform {
includeTags 'web-advanced'
}
failOnNoDiscoveredTests = true
outputs.upToDateWhen { false }
jvmArgs '-Duser.timezone=UTC'
}
tasks.register('webJettyCompatTest', Test) {
group = 'verification'
description = 'Runs the Stable HTTP contract against a real Jetty instead of Tomcat.'
testClassesDirs = sourceSets.jettyCompatTest.output.classesDirs
classpath = sourceSets.jettyCompatTest.runtimeClasspath
useJUnitPlatform()
failOnNoDiscoveredTests = true
outputs.upToDateWhen { false }
jvmArgs '-Duser.timezone=UTC'
}
strictTestLanes {
lane('jpaPersistenceRedactionContractTest') {
description = 'Runs the exact persistence error log/trace redaction contract used by JPA evidence.'
+237 -148
View File
@@ -1,188 +1,277 @@
# This is a Gradle generated file for dependency locking.
# Manual edits can break the build and are not advised.
# This file is expected to be part of source control.
biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,testCompileClasspath
ch.qos.logback:logback-classic:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
ch.qos.logback:logback-core:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson.core:jackson-annotations:2.20=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson.core:jackson-core:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson.core:jackson-databind:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml.jackson:jackson-bom:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.fasterxml:classmate:1.7.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor
biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,jettyCompatTestCompileClasspath,nginxProxyTestCompileClasspath,testCompileClasspath,testkitCompileClasspath,webfluxContractTestCompileClasspath
ch.qos.logback:logback-classic:1.5.38=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
ch.qos.logback:logback-core:1.5.38=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
com.fasterxml.jackson.core:jackson-annotations:2.21=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
com.fasterxml.jackson.core:jackson-core:2.21.5=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
com.fasterxml.jackson.core:jackson-databind:2.21.5=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.21.5=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.21.5=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
com.fasterxml.jackson:jackson-bom:2.21.5=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
com.fasterxml.woodstox:woodstox-core:7.1.1=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
com.fasterxml:classmate:1.7.3=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,jettyCompatTestAnnotationProcessor,nginxProxyTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor,webfluxContractTestAnnotationProcessor
com.github.docker-java:docker-java-api:3.7.1=nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath
com.github.docker-java:docker-java-transport-zerodep:3.7.1=nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath
com.github.docker-java:docker-java-transport:3.7.1=nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,jettyCompatTestAnnotationProcessor,nginxProxyTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor,webfluxContractTestAnnotationProcessor
com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs
com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,testCompileClasspath
com.github.spotbugs:spotbugs:4.10.2=spotbugs
com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs
com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor
com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor
com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor
com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,spotbugs,testCompileClasspath
com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,jettyCompatTestAnnotationProcessor,nginxProxyTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor,webfluxContractTestAnnotationProcessor
com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,jettyCompatTestAnnotationProcessor,nginxProxyTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor,webfluxContractTestAnnotationProcessor
com.google.auto:auto-common:1.2.2=annotationProcessor,jettyCompatTestAnnotationProcessor,nginxProxyTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor,webfluxContractTestAnnotationProcessor
com.google.code.findbugs:jsr305:3.0.2=checkstyle,spotbugs
com.google.code.gson:gson:2.13.2=spotbugs
com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,testCompileClasspath
com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,jettyCompatTestAnnotationProcessor,nginxProxyTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor,webfluxContractTestAnnotationProcessor
com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,jettyCompatTestCompileClasspath,nginxProxyTestCompileClasspath,testCompileClasspath,testkitCompileClasspath,webfluxContractTestCompileClasspath
com.google.errorprone:error_prone_annotations:2.41.0=spotbugs
com.google.errorprone:error_prone_annotations:2.47.0=checkstyle
com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor
com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor
com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor
com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor
com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,jettyCompatTestAnnotationProcessor,nginxProxyTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor,webfluxContractTestAnnotationProcessor
com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,jettyCompatTestAnnotationProcessor,nginxProxyTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor,webfluxContractTestAnnotationProcessor
com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,jettyCompatTestAnnotationProcessor,nginxProxyTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor,webfluxContractTestAnnotationProcessor
com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,jettyCompatTestAnnotationProcessor,nginxProxyTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor,webfluxContractTestAnnotationProcessor
com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,jettyCompatTestAnnotationProcessor,nginxProxyTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor,webfluxContractTestAnnotationProcessor
com.google.guava:guava:33.5.0-jre=annotationProcessor,jettyCompatTestAnnotationProcessor,nginxProxyTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor,webfluxContractTestAnnotationProcessor
com.google.guava:guava:33.6.0-jre=checkstyle
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,testAnnotationProcessor
com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor
com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,jettyCompatTestAnnotationProcessor,nginxProxyTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor,webfluxContractTestAnnotationProcessor
com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,jettyCompatTestAnnotationProcessor,nginxProxyTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor,webfluxContractTestAnnotationProcessor
com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,jettyCompatTestAnnotationProcessor,nginxProxyTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor,webfluxContractTestAnnotationProcessor
com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins
com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath
com.nimbusds:nimbus-jose-jwt:10.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
com.jayway.jsonpath:json-path:2.10.0=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
com.nimbusds:nimbus-jose-jwt:10.4=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
com.puppycrawl.tools:checkstyle:13.5.0=checkstyle
com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath
com.tngtech.archunit:archunit-junit5-api:1.3.0=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
com.tngtech.archunit:archunit-junit5-engine-api:1.3.0=jettyCompatTestRuntimeClasspath,nginxProxyTestRuntimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath
com.tngtech.archunit:archunit-junit5-engine:1.3.0=jettyCompatTestRuntimeClasspath,nginxProxyTestRuntimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath
com.tngtech.archunit:archunit-junit5:1.3.0=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
com.tngtech.archunit:archunit:1.3.0=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
com.vaadin.external.google:android-json:0.0.20131108.vaadin1=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
commons-beanutils:commons-beanutils:1.11.0=checkstyle
commons-codec:commons-codec:1.19.0=nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath
commons-collections:commons-collections:3.2.2=checkstyle
commons-io:commons-io:2.20.0=nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath
commons-io:commons-io:2.21.0=spotbugs
commons-logging:commons-logging:1.3.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
commons-logging:commons-logging:1.3.6=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
info.picocli:picocli:4.7.7=checkstyle
io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor
io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.projectreactor:reactor-core:3.8.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.projectreactor:reactor-test:3.8.0=testCompileClasspath,testRuntimeClasspath
io.swagger.core.v3:swagger-annotations-jakarta:2.2.38=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.swagger.core.v3:swagger-core-jakarta:2.2.38=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
io.swagger.core.v3:swagger-models-jakarta:2.2.38=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.activation:jakarta.activation-api:2.1.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.validation:jakarta.validation-api:3.1.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor
jaxen:jaxen:2.0.0=spotbugs
net.bytebuddy:byte-buddy-agent:1.17.8=testCompileClasspath,testRuntimeClasspath
net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath
net.minidev:accessors-smart:2.6.0=testCompileClasspath,testRuntimeClasspath
net.minidev:json-smart:2.6.0=testCompileClasspath,testRuntimeClasspath
io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,jettyCompatTestAnnotationProcessor,nginxProxyTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor,webfluxContractTestAnnotationProcessor
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,jettyCompatTestAnnotationProcessor,nginxProxyTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor,webfluxContractTestAnnotationProcessor
io.micrometer:micrometer-commons:1.16.7=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
io.micrometer:micrometer-observation:1.16.7=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
io.netty:netty-buffer:4.2.17.Final=webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
io.netty:netty-codec-base:4.2.17.Final=webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
io.netty:netty-codec-classes-quic:4.2.17.Final=webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
io.netty:netty-codec-compression:4.2.17.Final=webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
io.netty:netty-codec-dns:4.2.17.Final=webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
io.netty:netty-codec-http2:4.2.17.Final=webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
io.netty:netty-codec-http3:4.2.17.Final=webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
io.netty:netty-codec-http:4.2.17.Final=webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
io.netty:netty-codec-native-quic:4.2.17.Final=webfluxContractTestRuntimeClasspath
io.netty:netty-codec-socks:4.2.17.Final=webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
io.netty:netty-common:4.2.17.Final=webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
io.netty:netty-handler-proxy:4.2.17.Final=webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
io.netty:netty-handler:4.2.17.Final=webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
io.netty:netty-resolver-dns-classes-macos:4.2.17.Final=webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
io.netty:netty-resolver-dns-native-macos:4.2.17.Final=webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
io.netty:netty-resolver-dns:4.2.17.Final=webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
io.netty:netty-resolver:4.2.17.Final=webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
io.netty:netty-transport-classes-epoll:4.2.17.Final=webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
io.netty:netty-transport-native-epoll:4.2.17.Final=webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
io.netty:netty-transport-native-unix-common:4.2.17.Final=webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
io.netty:netty-transport:4.2.17.Final=webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
io.projectreactor.netty:reactor-netty-core:1.3.7=webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
io.projectreactor.netty:reactor-netty-http:1.3.7=webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
io.projectreactor:reactor-core:3.8.7=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
io.projectreactor:reactor-test:3.8.7=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
io.swagger.core.v3:swagger-annotations-jakarta:2.2.38=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
io.swagger.core.v3:swagger-core-jakarta:2.2.38=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
io.swagger.core.v3:swagger-models-jakarta:2.2.38=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
jakarta.activation:jakarta.activation-api:2.1.4=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
jakarta.enterprise:jakarta.enterprise.cdi-api:4.1.0=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath
jakarta.enterprise:jakarta.enterprise.lang-model:4.1.0=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath
jakarta.inject:jakarta.inject-api:2.0.1=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath
jakarta.interceptor:jakarta.interceptor-api:2.2.0=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath
jakarta.servlet:jakarta.servlet-api:6.1.0=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath
jakarta.transaction:jakarta.transaction-api:2.0.1=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath
jakarta.validation:jakarta.validation-api:3.1.1=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
jakarta.websocket:jakarta.websocket-api:2.2.0=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath
jakarta.websocket:jakarta.websocket-client-api:2.2.0=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath
jakarta.xml.bind:jakarta.xml.bind-api:4.0.5=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
javax.inject:javax.inject:1=annotationProcessor,jettyCompatTestAnnotationProcessor,nginxProxyTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor,webfluxContractTestAnnotationProcessor
jaxen:jaxen:2.0.6=spotbugs
net.bytebuddy:byte-buddy-agent:1.17.8=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
net.bytebuddy:byte-buddy:1.17.8=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
net.java.dev.jna:jna:5.18.1=nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath
net.minidev:accessors-smart:2.6.0=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
net.minidev:json-smart:2.6.0=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs
org.antlr:antlr4-runtime:4.13.2=checkstyle
org.apache.bcel:bcel:6.12.0=spotbugs
org.apache.commons:commons-lang3:3.20.0=checkstyle,compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
org.apache.commons:commons-compress:1.28.0=nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath
org.apache.commons:commons-lang3:3.20.0=checkstyle,compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.apache.commons:commons-text:1.15.0=spotbugs
org.apache.commons:commons-text:1.3=checkstyle
org.apache.httpcomponents:httpclient:4.5.13=checkstyle
org.apache.httpcomponents:httpcore:4.4.16=checkstyle
org.apache.logging.log4j:log4j-api:2.25.2=compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
org.apache.logging.log4j:log4j-core:2.25.2=spotbugs
org.apache.logging.log4j:log4j-to-slf4j:2.25.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.logging.log4j:log4j-api:2.25.5=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
org.apache.logging.log4j:log4j-core:2.25.5=spotbugs
org.apache.logging.log4j:log4j-to-slf4j:2.25.5=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
org.apache.maven.doxia:doxia-core:1.12.0=checkstyle
org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle
org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle
org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle
org.apache.tomcat.embed:tomcat-embed-core:11.0.14=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-el:11.0.14=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-core:11.0.24=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-el:11.0.24=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
org.apache.tomcat.embed:tomcat-embed-websocket:11.0.24=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.apache.xbean:xbean-reflect:3.7=checkstyle
org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath
org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath
org.awaitility:awaitility:4.3.0=testCompileClasspath,testRuntimeClasspath
org.apiguardian:apiguardian-api:1.1.2=jettyCompatTestCompileClasspath,nginxProxyTestCompileClasspath,testCompileClasspath,testkitCompileClasspath,webfluxContractTestCompileClasspath
org.assertj:assertj-core:3.27.7=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
org.awaitility:awaitility:4.3.0=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle
org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle
org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle
org.codehaus.plexus:plexus-utils:3.3.0=checkstyle
org.codehaus.woodstox:stax2-api:4.2.2=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.dom4j:dom4j:2.2.0=spotbugs
org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath
org.hibernate.validator:hibernate-validator:9.0.1.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.eclipse.jetty.compression:jetty-compression-common:12.1.12=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath
org.eclipse.jetty.compression:jetty-compression-gzip:12.1.12=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath
org.eclipse.jetty.compression:jetty-compression-server:12.1.12=jettyCompatTestRuntimeClasspath
org.eclipse.jetty.ee11.websocket:jetty-ee11-websocket-jakarta-client:12.1.12=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath
org.eclipse.jetty.ee11.websocket:jetty-ee11-websocket-jakarta-common:12.1.12=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath
org.eclipse.jetty.ee11.websocket:jetty-ee11-websocket-jakarta-server:12.1.12=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath
org.eclipse.jetty.ee11.websocket:jetty-ee11-websocket-jetty-server:12.1.12=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath
org.eclipse.jetty.ee11.websocket:jetty-ee11-websocket-servlet:12.1.12=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath
org.eclipse.jetty.ee11:jetty-ee11-annotations:12.1.12=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath
org.eclipse.jetty.ee11:jetty-ee11-plus:12.1.12=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath
org.eclipse.jetty.ee11:jetty-ee11-servlet:12.1.12=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath
org.eclipse.jetty.ee11:jetty-ee11-webapp:12.1.12=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath
org.eclipse.jetty.ee:jetty-ee-webapp:12.1.12=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath
org.eclipse.jetty.websocket:jetty-websocket-core-client:12.1.12=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath
org.eclipse.jetty.websocket:jetty-websocket-core-common:12.1.12=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath
org.eclipse.jetty.websocket:jetty-websocket-core-server:12.1.12=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath
org.eclipse.jetty.websocket:jetty-websocket-jetty-api:12.1.12=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath
org.eclipse.jetty.websocket:jetty-websocket-jetty-common:12.1.12=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath
org.eclipse.jetty.websocket:jetty-websocket-jetty-server:12.1.12=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath
org.eclipse.jetty:jetty-alpn-client:12.1.12=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath
org.eclipse.jetty:jetty-annotations:12.1.12=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath
org.eclipse.jetty:jetty-client:12.1.12=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath
org.eclipse.jetty:jetty-http:12.1.12=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath
org.eclipse.jetty:jetty-io:12.1.12=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath
org.eclipse.jetty:jetty-plus:12.1.12=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath
org.eclipse.jetty:jetty-security:12.1.12=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath
org.eclipse.jetty:jetty-server:12.1.12=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath
org.eclipse.jetty:jetty-session:12.1.12=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath
org.eclipse.jetty:jetty-util:12.1.12=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath
org.eclipse.jetty:jetty-xml:12.1.12=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath
org.hamcrest:hamcrest:3.0=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
org.hibernate.validator:hibernate-validator:9.0.1.Final=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
org.javassist:javassist:3.28.0-GA=checkstyle
org.jboss.logging:jboss-logging:3.6.1.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath
org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath
org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath
org.junit.platform:junit-platform-engine:6.0.1=testRuntimeClasspath
org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath
org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath
org.jboss.logging:jboss-logging:3.6.3.Final=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
org.jetbrains:annotations:17.0.0=nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath
org.jspecify:jspecify:1.0.1=annotationProcessor,checkstyle,compileClasspath,jettyCompatTestAnnotationProcessor,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestAnnotationProcessor,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath,testkitAnnotationProcessor,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestAnnotationProcessor,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
org.junit.jupiter:junit-jupiter-api:6.0.3=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
org.junit.jupiter:junit-jupiter-engine:6.0.3=jettyCompatTestRuntimeClasspath,nginxProxyTestRuntimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath,webfluxContractTestRuntimeClasspath
org.junit.jupiter:junit-jupiter-params:6.0.3=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
org.junit.jupiter:junit-jupiter:6.0.3=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
org.junit.platform:junit-platform-commons:6.0.3=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
org.junit.platform:junit-platform-engine:6.0.3=jettyCompatTestRuntimeClasspath,nginxProxyTestRuntimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath,webfluxContractTestRuntimeClasspath
org.junit.platform:junit-platform-launcher:6.0.3=jettyCompatTestRuntimeClasspath,nginxProxyTestRuntimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath,webfluxContractTestRuntimeClasspath
org.junit:junit-bom:6.0.3=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
org.junit:junit-bom:6.1.0=spotbugs
org.mockito:mockito-core:5.20.0=mockitoAgent,testCompileClasspath,testRuntimeClasspath
org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath
org.objenesis:objenesis:3.3=testRuntimeClasspath
org.openapitools:jackson-databind-nullable:0.2.6=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath
org.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,testCompileClasspath
org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,testCompileClasspath
org.osgi:org.osgi.resource:1.0.0=compileClasspath,testCompileClasspath
org.osgi:org.osgi.service.serviceloader:1.0.0=compileClasspath,testCompileClasspath
org.mockito:mockito-core:5.20.0=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,mockitoAgent,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
org.mockito:mockito-junit-jupiter:5.20.0=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
org.objenesis:objenesis:3.3=jettyCompatTestRuntimeClasspath,nginxProxyTestRuntimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath,webfluxContractTestRuntimeClasspath
org.openapitools:jackson-databind-nullable:0.2.6=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.opentest4j:opentest4j:1.3.0=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
org.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,jettyCompatTestCompileClasspath,nginxProxyTestCompileClasspath,testCompileClasspath,testkitCompileClasspath,webfluxContractTestCompileClasspath
org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,jettyCompatTestCompileClasspath,nginxProxyTestCompileClasspath,testCompileClasspath,testkitCompileClasspath,webfluxContractTestCompileClasspath
org.osgi:org.osgi.resource:1.0.0=compileClasspath,jettyCompatTestCompileClasspath,nginxProxyTestCompileClasspath,testCompileClasspath,testkitCompileClasspath,webfluxContractTestCompileClasspath
org.osgi:org.osgi.service.serviceloader:1.0.0=compileClasspath,jettyCompatTestCompileClasspath,nginxProxyTestCompileClasspath,testCompileClasspath,testkitCompileClasspath,webfluxContractTestCompileClasspath
org.ow2.asm:asm-analysis:9.10.1=spotbugs
org.ow2.asm:asm-commons:9.10.1=spotbugs
org.ow2.asm:asm-tree:9.10.1=spotbugs
org.ow2.asm:asm-commons:9.10.1=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,spotbugs
org.ow2.asm:asm-tree:9.10.1=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,spotbugs
org.ow2.asm:asm-util:9.10.1=spotbugs
org.ow2.asm:asm:9.10.1=spotbugs
org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath
org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor
org.reactivestreams:reactive-streams:1.0.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.ow2.asm:asm:9.10.1=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,spotbugs
org.ow2.asm:asm:9.7.1=nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
org.pcollections:pcollections:4.0.1=annotationProcessor,jettyCompatTestAnnotationProcessor,nginxProxyTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor,webfluxContractTestAnnotationProcessor
org.reactivestreams:reactive-streams:1.0.4=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
org.reflections:reflections:0.10.2=checkstyle
org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath
org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath
org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j
org.springdoc:springdoc-openapi-starter-common:3.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springdoc:springdoc-openapi-starter-webmvc-api:3.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-configuration-processor:4.0.0=annotationProcessor
org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-http-converter:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-jackson:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-restclient:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-security-oauth2-resource-server:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-security:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-servlet:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-oauth2-resource-server:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-security:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-tomcat:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-validation:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-web:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-starter:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-tomcat:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-validation:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-web-server:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot-webmvc:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.boot:spring-boot:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-config:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-core:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-crypto:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-oauth2-core:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-oauth2-jose:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-oauth2-resource-server:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-test:7.0.0=testCompileClasspath,testRuntimeClasspath
org.springframework.security:spring-security-web:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework.session:spring-session-core:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-aop:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-beans:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-context:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-core:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-expression:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-test:7.0.1=testCompileClasspath,testRuntimeClasspath
org.springframework:spring-web:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-webflux:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.springframework:spring-webmvc:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.rnorth.duct-tape:duct-tape:1.0.8=nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath
org.skyscreamer:jsonassert:1.5.3=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
org.slf4j:jul-to-slf4j:2.0.18=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
org.slf4j:slf4j-api:2.0.18=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j
org.slf4j:slf4j-simple:2.0.18=checkstyle
org.springdoc:springdoc-openapi-starter-common:3.0.0=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springdoc:springdoc-openapi-starter-webmvc-api:3.0.0=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-autoconfigure:4.0.8=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
org.springframework.boot:spring-boot-configuration-processor:4.0.8=annotationProcessor
org.springframework.boot:spring-boot-http-codec:4.0.8=webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
org.springframework.boot:spring-boot-http-converter:4.0.8=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-jackson:4.0.8=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
org.springframework.boot:spring-boot-jetty:4.0.8=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath
org.springframework.boot:spring-boot-netty:4.0.8=webfluxContractTestRuntimeClasspath
org.springframework.boot:spring-boot-reactor-netty:4.0.8=webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
org.springframework.boot:spring-boot-reactor:4.0.8=webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
org.springframework.boot:spring-boot-resttestclient:4.0.8=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-security-oauth2-resource-server:4.0.8=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-security:4.0.8=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-servlet:4.0.8=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson-test:4.0.8=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-starter-jackson:4.0.8=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
org.springframework.boot:spring-boot-starter-jetty-runtime:4.0.8=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath
org.springframework.boot:spring-boot-starter-jetty:4.0.8=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath
org.springframework.boot:spring-boot-starter-logging:4.0.8=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
org.springframework.boot:spring-boot-starter-oauth2-resource-server:4.0.8=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-starter-reactor-netty:4.0.8=webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
org.springframework.boot:spring-boot-starter-security:4.0.8=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-starter-test:4.0.8=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.8=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-starter-tomcat:4.0.8=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-starter-validation:4.0.8=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
org.springframework.boot:spring-boot-starter-web:4.0.8=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-starter-webflux:4.0.8=webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
org.springframework.boot:spring-boot-starter-webmvc-test:4.0.8=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-starter-webmvc:4.0.8=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-starter:4.0.8=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
org.springframework.boot:spring-boot-test-autoconfigure:4.0.8=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
org.springframework.boot:spring-boot-test:4.0.8=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
org.springframework.boot:spring-boot-tomcat:4.0.8=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-validation:4.0.8=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
org.springframework.boot:spring-boot-web-server:4.0.8=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
org.springframework.boot:spring-boot-webflux:4.0.8=webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
org.springframework.boot:spring-boot-webmvc-test:4.0.8=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot-webmvc:4.0.8=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.boot:spring-boot:4.0.8=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
org.springframework.security:spring-security-config:7.0.7=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.security:spring-security-core:7.0.7=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.security:spring-security-crypto:7.0.7=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.security:spring-security-oauth2-core:7.0.7=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.security:spring-security-oauth2-jose:7.0.7=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.security:spring-security-oauth2-resource-server:7.0.7=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.security:spring-security-test:7.0.7=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.security:spring-security-web:7.0.7=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework.session:spring-session-core:4.0.5=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.springframework:spring-aop:7.0.9=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
org.springframework:spring-beans:7.0.9=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
org.springframework:spring-context:7.0.9=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
org.springframework:spring-core:7.0.9=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
org.springframework:spring-expression:7.0.9=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
org.springframework:spring-test:7.0.9=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
org.springframework:spring-web:7.0.9=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
org.springframework:spring-webflux:7.0.9=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
org.springframework:spring-webmvc:7.0.9=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
org.testcontainers:testcontainers-junit-jupiter:2.0.5=nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath
org.testcontainers:testcontainers:2.0.5=nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath
org.yaml:snakeyaml:2.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson.core:jackson-core:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson.core:jackson-databind:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
tools.jackson:jackson-bom:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.xmlunit:xmlunit-core:2.10.4=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
org.yaml:snakeyaml:2.5=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
tools.jackson.core:jackson-core:3.1.5=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
tools.jackson.core:jackson-databind:3.1.5=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
tools.jackson.dataformat:jackson-dataformat-cbor:3.1.5=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
tools.jackson.dataformat:jackson-dataformat-xml:3.1.5=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
tools.jackson:jackson-bom:3.1.5=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
empty=
@@ -0,0 +1,30 @@
package dev.caskeleton.adapter.inbound.web.testkit.budget;
import dev.caskeleton.webtestkit.BudgetFixtureApplication;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.test.context.ActiveProfiles;
/**
* Budget enforcement on Jetty.
*
* <p>Containers impose bounds of their own — on the request line, on header size, on how a body is
* fed to a filter — and they differ. Running the same contract on the second one is what tells us
* whether a refusal came from the platform or from Tomcat.
*/
@SpringBootTest(
classes = BudgetFixtureApplication.class,
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
// The budget handler is gated on this property. Turning it on here rather than defaulting it
// on keeps the production default off: a control that is on by default is one nobody notices.
properties = "backend.web.budgets.enabled=true")
@ActiveProfiles("web-contract")
class JettyWebBudgetIT extends WebBudgetContract {
@LocalServerPort private int port;
@Override
protected HttpBudgetFixture fixture() {
return new HttpBudgetFixture(port);
}
}
@@ -0,0 +1,26 @@
package dev.caskeleton.adapter.inbound.web.testkit.contract;
import dev.caskeleton.webtestkit.ContractFixtureApplication;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.test.context.ActiveProfiles;
/** Records the wire contract as the second servlet container serves it. */
@SpringBootTest(
classes = ContractFixtureApplication.class,
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("web-contract")
class JettyContractRecordingIT extends WebPlatformContractRecording {
@LocalServerPort private int port;
@Override
protected WebContractFixture fixture() {
return new WebContractFixture(port);
}
@Override
protected String laneName() {
return "jetty";
}
}
@@ -0,0 +1,28 @@
package dev.caskeleton.adapter.inbound.web.testkit.fault;
import dev.caskeleton.webtestkit.ContractFixtureApplication;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.test.context.ActiveProfiles;
/**
* The response-loss contract on Jetty.
*
* <p>Run on the second container because losing a response is exactly where containers differ: they
* detect a departed client at different moments and unwind the request differently. The contract
* they must both keep — the write happened once, the retry recovers it — is the same, so it is
* asserted from the same shared class rather than restated here.
*/
@SpringBootTest(
classes = ContractFixtureApplication.class,
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("web-contract")
class JettyCommitThenConnectionResetIT extends IdempotencyResponseLossContract {
@LocalServerPort private int port;
@Override
protected ResponseLossFixture fixture() {
return new HttpResponseLossFixture(port);
}
}
@@ -0,0 +1,48 @@
package dev.caskeleton.adapter.inbound.web.testkit.mvc;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.webtestkit.ContractFixtureApplication;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.boot.web.server.servlet.context.ServletWebServerApplicationContext;
import org.springframework.test.context.ActiveProfiles;
/**
* The same Stable HTTP contract, on Jetty.
*
* <p>The same one: {@link WebContractAssertions} is shared with the Tomcat lane rather than copied,
* because the design's rule is that container-specific behaviour is never imported back into the
* common core, and two copies is how the second one quietly becomes a different contract.
*
* <p>The first assertion checks which server actually started. Without it a classpath change that
* put Tomcat back would leave this lane green while certifying the same container twice — the
* failure mode a compatibility matrix exists to prevent and the one it is worst at detecting.
*/
@SpringBootTest(
classes = ContractFixtureApplication.class,
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("web-contract")
class JettyWebContractIT {
@LocalServerPort private int port;
@Autowired private ServletWebServerApplicationContext context;
@Test
@DisplayName("the lane is actually running on Jetty")
void theLaneIsActuallyRunningOnJetty() {
assertThat(context.getWebServer().getClass().getName())
.as("with Tomcat still on the classpath this lane would certify the same container twice")
.contains("Jetty");
}
@Test
@DisplayName("the whole Stable HTTP contract holds on Jetty")
void theWholeStableContractHoldsOnJetty() throws Exception {
new WebContractAssertions(port).assertWholeContract();
}
}
@@ -0,0 +1,27 @@
package dev.caskeleton.adapter.inbound.web.testkit.operation;
import dev.caskeleton.webtestkit.ContractFixtureApplication;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.test.context.ActiveProfiles;
/**
* The operation resource contract on Jetty.
*
* <p>Containers differ in how they normalise a {@code Location} and whether they keep a {@code
* Content-Location} on a 200. Those are exactly the headers this contract turns on, so running it
* on the second servlet container is not redundancy.
*/
@SpringBootTest(
classes = ContractFixtureApplication.class,
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("web-contract")
class JettyOperationHttpIT extends OperationHttpContract {
@LocalServerPort private int port;
@Override
protected HttpOperationFixture fixture() {
return new HttpOperationFixture(port);
}
}
@@ -0,0 +1,28 @@
package dev.caskeleton.adapter.inbound.web.testkit.order;
import dev.caskeleton.webtestkit.PipelineOrderFixtureApplication;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.test.context.ActiveProfiles;
/**
* Pipeline order on Jetty.
*
* <p>Async redispatch is a servlet-container mechanism, and the two containers implement it
* separately — including when {@code isAsyncStarted} becomes true relative to the filter chain
* unwinding. The duplicate-observation guard turns on exactly that timing, so certifying it on one
* container says nothing about the other.
*/
@SpringBootTest(
classes = PipelineOrderFixtureApplication.class,
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("web-contract")
class JettyPipelineOrderIT extends WebPipelineOrderContract {
@LocalServerPort private int port;
@Override
protected HttpPipelineFixture fixture() {
return new HttpPipelineFixture(port);
}
}
@@ -0,0 +1,63 @@
package dev.caskeleton.adapter.inbound.web.testkit.performance;
import static org.assertj.core.api.Assertions.assertThat;
import dev.caskeleton.webtestkit.ContractFixtureApplication;
import java.time.Duration;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.boot.web.server.servlet.context.ServletWebServerApplicationContext;
import org.springframework.test.context.ActiveProfiles;
/**
* Load, abuse and graceful shutdown on the the second servlet container.
*
* <p>Shutdown is asserted per container rather than once, because it is implemented per container.
* "Stop accepting, finish what is in flight" is a promise each server keeps in its own way, and the
* failure — a request cut off mid-response during a rolling deploy — looks to the client exactly
* like the network.
*/
@SpringBootTest(
classes = ContractFixtureApplication.class,
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = "server.shutdown=graceful")
@ActiveProfiles("web-contract")
class JettyLoadAndShutdownIT extends WebLoadAndShutdownContract {
@LocalServerPort private int port;
@Autowired private ServletWebServerApplicationContext context;
@Override
protected WebLoadFixture fixture() {
return new WebLoadFixture(port);
}
@Override
protected org.springframework.boot.web.server.WebServer webServer() {
return context.getWebServer();
}
@Test
@Tag("web-shutdown")
@DisplayName("graceful shutdown drains within the deployment's grace period")
void gracefulShutdownFinishesInFlightWork() {
// Last, and destructive: the context serves nothing afterwards.
WebLoadFixture fixture = fixture();
assertThat(fixture.stillServing(loadPath())).isTrue();
GracefulShutdownProbe.Outcome outcome =
GracefulShutdownProbe.shutDown(webServer(), Duration.ofSeconds(10));
// Bounded on purpose. A shutdown that waits indefinitely for a connection to go idle is how a
// rolling deploy stalls with half the fleet drained — and it produces no error to alert on.
assertThat(outcome.took()).isLessThan(Duration.ofSeconds(15));
assertThat(fixture.stillServing(loadPath()))
.as("the server accepted a new request after it was told to stop")
.isFalse();
}
}
@@ -0,0 +1,41 @@
package dev.caskeleton.adapter.inbound.web.testkit.throttle;
import dev.caskeleton.webtestkit.ThrottleFixtureApplication;
import org.junit.jupiter.api.AfterEach;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.test.context.ActiveProfiles;
/**
* Quota and capacity refusals on the second servlet container.
*
* <p>Real, because the capacity case needs one request to genuinely occupy a slot while another
* arrives. A mock dispatcher runs them one after the other, so the second never meets a full
* service and the 503 case would pass without ever having been exercised.
*/
@SpringBootTest(
classes = ThrottleFixtureApplication.class,
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("web-contract")
class JettyWebThrottleIT extends WebThrottleHttpContract {
@LocalServerPort private int port;
private HttpThrottleFixture fixture;
@Override
protected HttpThrottleFixture fixture() {
if (fixture == null) {
fixture = new HttpThrottleFixture(port);
}
return fixture;
}
@AfterEach
void closeFixture() {
if (fixture != null) {
fixture.close();
fixture = null;
}
}
}
@@ -0,0 +1,19 @@
# The profile the real-container contract gate runs under.
#
# Everything is switched off except the servlet transport itself. The gate is about the status
# contract on the wire, and a security chain or a file-server profile joining the context would make
# a failure here ambiguous between "the contract broke" and "an unrelated capability did".
spring:
main:
banner-mode: "off"
mvc:
problemdetails:
enabled: true
server:
error:
include-stacktrace: never
include-message: never
backend:
web:
mvc:
enabled: true
@@ -0,0 +1,50 @@
package dev.caskeleton.adapter.inbound.web.admin.platform;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
* What the platform is actually configured to do, as an operator can read it.
*
* <p>Read from the running objects, never from the configuration that was supposed to produce them.
* Every incident where a control was "enabled" and did nothing comes down to the same gap: the
* property was set, the bean was not created, and the only thing anyone could inspect was the
* property. A snapshot built from the beans reports the second fact.
*
* @param transport which stack is serving, "servlet" or "reactive"
* @param apiVersions the API major versions being served
* @param problemCodes every failure code this deployment can publish
* @param budgetProfiles the registered request budgets, by name
* @param admissionProfiles the registered admission profiles, by name
* @param cacheProfiles the registered cache profiles, by name
* @param installedControls which platform controls are actually wired, by name
*/
public record WebPlatformSnapshot(
String transport,
List<Integer> apiVersions,
List<String> problemCodes,
Map<String, String> budgetProfiles,
Map<String, String> admissionProfiles,
List<String> cacheProfiles,
Map<String, Boolean> installedControls) {
public WebPlatformSnapshot {
Objects.requireNonNull(transport, "transport");
apiVersions = List.copyOf(apiVersions);
problemCodes = List.copyOf(problemCodes);
budgetProfiles = Map.copyOf(budgetProfiles);
admissionProfiles = Map.copyOf(admissionProfiles);
cacheProfiles = List.copyOf(cacheProfiles);
installedControls = Map.copyOf(installedControls);
}
/** The controls that are declared but not wired. */
public List<String> uninstalledControls() {
return installedControls.entrySet().stream()
.filter(entry -> !entry.getValue())
.map(Map.Entry::getKey)
.sorted()
.toList();
}
}
@@ -0,0 +1,62 @@
package dev.caskeleton.adapter.inbound.web.admin.platform;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
/**
* Refuses to start when a declared control is not actually wired.
*
* <p>At startup, because the alternative is finding out from an incident. A control that is
* configured and not installed behaves exactly like one that is working right up until the moment
* it is needed — the rate limiter that never limits, the budget that never bounds, the problem
* catalog that nothing consults. This session found the third of those by accident, through a
* parity recording; a startup check is what finds the next one on purpose.
*
* <p>Fail-closed. A validator that logged a warning would be read by nobody: the deployment starts,
* the dashboards are green, and the warning scrolls past in the first thirty seconds of a log
* nobody keeps.
*/
public final class WebPlatformStartupValidator {
private final List<String> required;
/**
* A validator over the controls this deployment claims.
*
* @param required the controls that must be wired
*/
public WebPlatformStartupValidator(List<String> required) {
this.required = List.copyOf(Objects.requireNonNull(required, "required"));
}
/**
* Refuses a snapshot that is missing a required control.
*
* @param snapshot what the running platform reports
* @throws IllegalStateException naming every missing control at once
*/
public void validate(WebPlatformSnapshot snapshot) {
Objects.requireNonNull(snapshot, "snapshot");
List<String> missing = new ArrayList<>();
for (String control : required) {
if (!Boolean.TRUE.equals(snapshot.installedControls().get(control))) {
missing.add(control);
}
}
if (!missing.isEmpty()) {
// Every one at once. Reporting the first sends an operator through as many restarts as there
// are problems, and each restart is a deploy.
throw new IllegalStateException(
"the web platform declares controls that are not wired: "
+ missing
+ ". A configured-but-uninstalled control is indistinguishable from a working one"
+ " until the moment it is needed, so startup fails here rather than in production.");
}
if (snapshot.problemCodes().isEmpty()) {
throw new IllegalStateException(
"no problem catalog is installed; every failure would be answered by the framework's"
+ " own document, which carries no code for a client to branch on");
}
}
}
@@ -0,0 +1,18 @@
package dev.caskeleton.adapter.inbound.web.admin.route;
/**
* The routes this deployment actually serves are not the routes it declared.
*
* <p>The failure the release gate is built around. A route inventory is only worth having if a
* disagreement with the approved manifest stops the release: an endpoint that appears without a
* review is an endpoint whose authorization, budget and idempotency policy nobody chose.
*/
public final class RouteInventoryMismatchException extends RuntimeException {
private static final long serialVersionUID = 1L;
/** Creates the failure. */
public RouteInventoryMismatchException(String message) {
super(message);
}
}
@@ -0,0 +1,138 @@
package dev.caskeleton.adapter.inbound.web.admin.route;
import dev.caskeleton.adapter.inbound.web.core.ApiMajorVersion;
import dev.caskeleton.adapter.inbound.web.core.WebOperationName;
import dev.caskeleton.adapter.inbound.web.core.WebRouteId;
import dev.caskeleton.adapter.inbound.web.operation.HttpMethodSemantic;
import dev.caskeleton.adapter.inbound.web.versioning.ApiDeprecationPolicy;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.mvc.method.RequestMappingInfo;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
/**
* Reads the routes Spring MVC actually registered.
*
* <p>From the handler mapping rather than from annotations. The mapping is what the dispatcher will
* consult, so it is the only source that reflects path prefixes, conditional registration and any
* programmatic mapping — an annotation scan describes what the code says and the mapping describes
* what the deployment serves, and the gate has to be about the second.
*
* <p>A mapping with no path or no method is skipped rather than guessed at: Spring permits both,
* and inventing "GET" or "/" for them would put a route in the manifest that nobody can call.
*/
public final class SpringMvcRouteInventoryCollector {
private final ApiDeprecationPolicy deprecationPolicy;
/**
* A collector that annotates routes with their deprecation state.
*
* @param deprecationPolicy the registered deprecations
*/
public SpringMvcRouteInventoryCollector(ApiDeprecationPolicy deprecationPolicy) {
this.deprecationPolicy = Objects.requireNonNull(deprecationPolicy, "deprecationPolicy");
}
/**
* Collects every registered route.
*
* @param mapping the dispatcher's handler mapping
* @param defaultVersion the version to record for a path that carries none
*/
public WebRouteInventory collect(
RequestMappingHandlerMapping mapping, ApiMajorVersion defaultVersion) {
Objects.requireNonNull(mapping, "mapping");
Objects.requireNonNull(defaultVersion, "defaultVersion");
WebRouteInventory inventory = new WebRouteInventory();
for (var entry : mapping.getHandlerMethods().entrySet()) {
RequestMappingInfo info = entry.getKey();
HandlerMethod handler = entry.getValue();
Set<String> patterns = patternsOf(info);
Set<org.springframework.web.bind.annotation.RequestMethod> methods =
info.getMethodsCondition().getMethods();
if (patterns.isEmpty() || methods.isEmpty()) {
continue;
}
for (String pattern : patterns) {
for (var method : methods) {
HttpMethodSemantic semantic = semanticOf(method.name());
if (semantic == null) {
continue;
}
inventory.add(contract(info, handler, pattern, semantic, defaultVersion));
}
}
}
return inventory;
}
private WebRouteContract contract(
RequestMappingInfo info,
HandlerMethod handler,
String pattern,
HttpMethodSemantic method,
ApiMajorVersion defaultVersion) {
String routeKey = method.name() + " " + pattern;
var deprecation = deprecationPolicy.find(routeKey);
return new WebRouteContract(
new WebRouteId(routeKey),
operationName(handler),
versionOf(pattern, defaultVersion),
method,
pattern,
List.copyOf(mediaTypes(info.getConsumesCondition().getConsumableMediaTypes())),
List.copyOf(mediaTypes(info.getProducesCondition().getProducibleMediaTypes())),
deprecation.isPresent(),
deprecation.flatMap(route -> route.sunsetAt()));
}
/**
* The operation name for a handler.
*
* <p>Derived from the controller and method names, lower-cased and hyphen-joined, because a
* handler that has not declared one still needs an identity the inventory can report — and the
* catalog check will then refuse it, which is the intended outcome for an unregistered route.
*/
private WebOperationName operationName(HandlerMethod handler) {
String type = handler.getBeanType().getSimpleName().replace("Controller", "");
String raw = (type + "." + handler.getMethod().getName()).toLowerCase(java.util.Locale.ROOT);
String normalised = raw.replaceAll("[^a-z0-9.-]", "-");
return new WebOperationName(normalised.length() >= 3 ? normalised : "route." + normalised);
}
private ApiMajorVersion versionOf(String pattern, ApiMajorVersion defaultVersion) {
var matcher = java.util.regex.Pattern.compile("^/api/v([1-9][0-9]*)(?:/|$)").matcher(pattern);
return matcher.find()
? new ApiMajorVersion(Integer.parseInt(matcher.group(1)))
: defaultVersion;
}
private static Set<String> patternsOf(RequestMappingInfo info) {
if (info.getPathPatternsCondition() != null) {
return info.getPathPatternsCondition().getPatternValues();
}
return Set.of();
}
private static List<String> mediaTypes(Set<org.springframework.http.MediaType> types) {
return types.stream().map(Object::toString).sorted().toList();
}
private static HttpMethodSemantic semanticOf(String name) {
for (HttpMethodSemantic candidate : HttpMethodSemantic.values()) {
if (candidate.name().equals(name)) {
return candidate;
}
}
return null;
}
/** The optional deprecation for a route key, exposed for a diagnostic. */
public Optional<?> deprecationFor(String routeKey) {
return deprecationPolicy.find(routeKey);
}
}
@@ -0,0 +1,69 @@
package dev.caskeleton.adapter.inbound.web.admin.route;
import dev.caskeleton.adapter.inbound.web.core.ApiMajorVersion;
import dev.caskeleton.adapter.inbound.web.core.WebOperationName;
import dev.caskeleton.adapter.inbound.web.core.WebRouteId;
import dev.caskeleton.adapter.inbound.web.operation.HttpMethodSemantic;
import java.time.Instant;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
/**
* One published route, as the inventory records it.
*
* <p>This is the unit the release gate compares. It carries the facts a reviewer needs to decide
* whether an endpoint should exist — what it is called, which version it belongs to, what it reads
* and writes, and whether it is on its way out — and nothing that changes between deployments, so
* the comparison is about the contract rather than about the environment.
*
* @param routeId the route identity
* @param operationName the registered operation this route serves
* @param apiVersion the major version it belongs to
* @param method the HTTP method
* @param pathTemplate the path template, never a resolved URI
* @param consumes the media types the route reads
* @param produces the media types the route writes
* @param deprecated whether the route is deprecated
* @param sunsetAt when it stops being served, when a date has been committed to
*/
public record WebRouteContract(
WebRouteId routeId,
WebOperationName operationName,
ApiMajorVersion apiVersion,
HttpMethodSemantic method,
String pathTemplate,
List<String> consumes,
List<String> produces,
boolean deprecated,
Optional<Instant> sunsetAt) {
public WebRouteContract {
Objects.requireNonNull(routeId, "routeId");
Objects.requireNonNull(operationName, "operationName");
Objects.requireNonNull(apiVersion, "apiVersion");
Objects.requireNonNull(method, "method");
Objects.requireNonNull(pathTemplate, "pathTemplate");
Objects.requireNonNull(sunsetAt, "sunsetAt");
if (!pathTemplate.startsWith("/")) {
throw new IllegalArgumentException("route path template must be absolute: " + pathTemplate);
}
consumes = List.copyOf(consumes);
produces = List.copyOf(produces);
if (!deprecated && sunsetAt.isPresent()) {
throw new IllegalArgumentException(
"a route with a sunset date is deprecated by definition: " + pathTemplate);
}
}
/**
* The key two routes may not share.
*
* <p>Method, path and version together. Two handlers on the same triple is not a routing
* ambiguity the framework resolves predictably — which one wins depends on registration order,
* and registration order depends on classpath scanning.
*/
public String uniquenessKey() {
return method.name() + " " + apiVersion.pathSegment() + " " + pathTemplate;
}
}
@@ -0,0 +1,104 @@
package dev.caskeleton.adapter.inbound.web.admin.route;
import dev.caskeleton.adapter.inbound.web.core.WebOperationName;
import dev.caskeleton.adapter.inbound.web.operation.WebOperationCatalog;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.TreeMap;
/**
* Every route this deployment serves, collected and checked.
*
* <p>Two rules, applied as routes arrive rather than at the end. A duplicate method-path-version is
* refused because which handler wins depends on registration order and registration order depends
* on classpath scanning — a routing decision nobody made. An operation name outside the catalog is
* refused because it means an endpoint reached production with no budget, authorization or
* idempotency policy.
*
* <p>The inventory is bounded and sorted so two runs of the same build produce the same manifest; a
* release gate that compares against a manifest cannot tolerate ordering that varies with scanning.
*/
public final class WebRouteInventory {
private final Map<String, WebRouteContract> routes = new TreeMap<>();
/**
* Records a route.
*
* @throws RouteInventoryMismatchException when the method-path-version triple is already taken
*/
public void add(WebRouteContract route) {
Objects.requireNonNull(route, "route");
WebRouteContract existing = routes.putIfAbsent(route.uniquenessKey(), route);
if (existing != null) {
throw new RouteInventoryMismatchException(
"duplicate route "
+ route.uniquenessKey()
+ "; which handler wins would depend on classpath scanning order");
}
}
/**
* Refuses any route whose operation is not registered.
*
* @param catalog the registered operations
* @throws RouteInventoryMismatchException naming every unregistered operation at once
*/
public void requireRegisteredOperations(WebOperationCatalog catalog) {
Objects.requireNonNull(catalog, "catalog");
List<String> unregistered = new ArrayList<>();
for (WebRouteContract route : routes.values()) {
WebOperationName name = route.operationName();
try {
catalog.require(name);
} catch (RuntimeException unknown) {
unregistered.add(route.uniquenessKey() + " -> " + name.value());
}
}
if (!unregistered.isEmpty()) {
throw new RouteInventoryMismatchException(
"routes serve unregistered operations, so they have no budget, authorization or"
+ " idempotency policy: "
+ unregistered);
}
}
/**
* Compares the served routes against an approved manifest.
*
* @param approvedKeys the uniqueness keys the manifest declares
* @throws RouteInventoryMismatchException when either side has something the other does not
*/
public void requireMatches(java.util.Set<String> approvedKeys) {
Objects.requireNonNull(approvedKeys, "approvedKeys");
List<String> added = new ArrayList<>(routes.keySet());
added.removeAll(approvedKeys);
List<String> removed = new ArrayList<>(approvedKeys);
removed.removeAll(routes.keySet());
if (!added.isEmpty() || !removed.isEmpty()) {
throw new RouteInventoryMismatchException(
"the served routes and the approved manifest disagree; added="
+ added
+ " removed="
+ removed);
}
}
/** Every recorded route, in a deterministic order. */
public Map<String, WebRouteContract> routes() {
return Map.copyOf(new LinkedHashMap<>(routes));
}
/** The uniqueness keys, for writing or comparing a manifest. */
public java.util.Set<String> keys() {
return java.util.Set.copyOf(routes.keySet());
}
/** How many routes are served. */
public int size() {
return routes.size();
}
}
@@ -0,0 +1,59 @@
package dev.caskeleton.adapter.inbound.web.admission;
import java.time.Duration;
import java.util.Objects;
import java.util.Optional;
/**
* Whether the service has room to run this request now.
*
* <p>Distinct from a quota decision in what it means and in what it is answered with. A quota is
* about the caller and is 429; this is about the service and is 503. The permit is carried here
* rather than returned separately so that releasing it is impossible to forget in one branch and
* not another.
*
* @param admitted whether the request may run
* @param permit what to close when it finishes, present only when admitted
* @param retryAfter how long to wait, present only when refused
* @param waited how long the request queued before this decision
*/
public record AdmissionDecision(
boolean admitted,
Optional<AdmissionPermit> permit,
Optional<Duration> retryAfter,
Duration waited) {
public AdmissionDecision {
Objects.requireNonNull(permit, "permit");
Objects.requireNonNull(retryAfter, "retryAfter");
Objects.requireNonNull(waited, "waited");
if (admitted != permit.isPresent()) {
throw new IllegalArgumentException(
"an admitted request holds a permit and a refused one does not; anything else leaks"
+ " capacity or releases what it never took");
}
if (admitted == retryAfter.isPresent()) {
throw new IllegalArgumentException("only a refused request is told when to come back");
}
}
/**
* The request may run.
*
* @param permit what to close when it finishes
* @param waited how long it queued
*/
public static AdmissionDecision admitted(AdmissionPermit permit, Duration waited) {
return new AdmissionDecision(true, Optional.of(permit), Optional.empty(), waited);
}
/**
* The service has no room.
*
* @param retryAfter how long to wait
* @param waited how long it queued before being turned away
*/
public static AdmissionDecision refused(Duration retryAfter, Duration waited) {
return new AdmissionDecision(false, Optional.empty(), Optional.of(retryAfter), waited);
}
}
@@ -0,0 +1,21 @@
package dev.caskeleton.adapter.inbound.web.admission;
/**
* The right to occupy one execution slot, given up when closed.
*
* <p>{@link AutoCloseable} so the release is a {@code try}-with-resources rather than a {@code
* finally} somebody has to remember. A permit that is not released is capacity the service never
* gets back, and the symptom — throughput decaying towards zero over hours — is one of the harder
* ones to attribute.
*
* <p>{@code close} is idempotent in every implementation here. A double release would return
* capacity that was never taken, which is the same bug in the opposite direction and admits more
* concurrent work than the profile allows.
*/
@FunctionalInterface
public interface AdmissionPermit extends AutoCloseable {
/** Gives the slot back. Calling twice releases nothing extra. */
@Override
void close();
}
@@ -0,0 +1,80 @@
package dev.caskeleton.adapter.inbound.web.admission;
import dev.caskeleton.adapter.inbound.web.operation.AdmissionProfileName;
import java.time.Duration;
import java.util.Objects;
/**
* How much of the service one class of work may occupy.
*
* <p>Separate profiles for separate work is the point. A single global limit sized for cheap reads
* lets a burst of expensive writes fill it and starve everything; sized for the writes, it is no
* limit at all for the reads. The design asks for at least a write profile and an expensive-query
* profile, and the factories below are those.
*
* @param name the profile's identity
* @param maxConcurrent how many may run at once
* @param maxQueued how many may wait
* @param maxQueueWait how long one may wait before being turned away
*/
public record AdmissionProfile(
AdmissionProfileName name, int maxConcurrent, int maxQueued, Duration maxQueueWait) {
public AdmissionProfile {
Objects.requireNonNull(name, "name");
Objects.requireNonNull(maxQueueWait, "maxQueueWait");
if (maxConcurrent <= 0) {
throw new IllegalArgumentException("a profile that admits nothing is not a profile");
}
if (maxQueued < 0) {
throw new IllegalArgumentException("a negative queue is not a queue");
}
// An unbounded queue is the failure this whole mechanism exists to prevent. It converts an
// overload into unbounded latency and memory growth: every caller is accepted, none is served
// in time, and the ones still waiting have long since given up and retried.
if (maxQueued > 10 * maxConcurrent) {
throw new IllegalArgumentException(
"a queue of "
+ maxQueued
+ " against "
+ maxConcurrent
+ " concurrent is effectively unbounded: everything is accepted, nothing is served"
+ " in time, and the far end has already retried");
}
if (maxQueueWait.isNegative()) {
throw new IllegalArgumentException("a negative queue wait is not a wait");
}
if (maxQueueWait.compareTo(Duration.ofSeconds(10)) > 0) {
throw new IllegalArgumentException(
"a queue wait over 10s outlives the client that is waiting; refusing quickly is the"
+ " kinder answer and the one that sheds load");
}
}
/**
* Global writes: few at once, a short queue, a short wait.
*
* <p>Writes contend on the same rows and the same connections, so more concurrency past a point
* buys nothing and costs lock contention.
*/
public static AdmissionProfile globalWrite() {
return new AdmissionProfile(
new AdmissionProfileName("global-write"), 32, 64, Duration.ofMillis(250));
}
/**
* Expensive queries: fewer still, and turned away rather than queued for long.
*
* <p>An expensive query that has been waiting is worse than one refused: by the time it runs the
* caller has usually gone, and the work is spent on a response nobody reads.
*/
public static AdmissionProfile expensiveQuery() {
return new AdmissionProfile(
new AdmissionProfileName("expensive-query"), 8, 8, Duration.ofMillis(100));
}
/** Ordinary reads: generous, because they are cheap and mostly bounded by IO. */
public static AdmissionProfile standard() {
return new AdmissionProfile(AdmissionProfileName.standard(), 256, 256, Duration.ofMillis(500));
}
}
@@ -0,0 +1,118 @@
package dev.caskeleton.adapter.inbound.web.admission;
import dev.caskeleton.adapter.inbound.web.operation.AdmissionProfileName;
import java.time.Duration;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
/**
* Bounded concurrency with a bounded queue, per profile.
*
* <p>A {@link Semaphore} per profile rather than one shared pool, so a saturated write profile
* cannot refuse a read. The queue is a separate counter rather than the semaphore's own waiter
* list, because the semaphore's list is unbounded: without the counter a spike parks every request
* thread on {@code tryAcquire} and the bounded-queue requirement is satisfied only on paper.
*
* <p>The two bounds do different jobs. Concurrency decides how much work runs; the queue decides
* how much hope is kept. A queue longer than the clients' own timeouts is worse than no queue —
* every slot is filled with a request whose caller has already given up and retried, so the service
* does the work twice and answers neither in time.
*/
public final class SemaphoreAdmissionController implements WebAdmissionController {
private record Gate(AdmissionProfile profile, Semaphore permits, AtomicInteger queued) {}
private final Map<AdmissionProfileName, Gate> gates = new ConcurrentHashMap<>();
/**
* A controller over the given profiles.
*
* @param profiles what may run, and how much of it
*/
public SemaphoreAdmissionController(AdmissionProfile... profiles) {
Objects.requireNonNull(profiles, "profiles");
for (AdmissionProfile profile : profiles) {
// Fair, so a request that has been queued longest goes next. Unfair acquisition is faster
// and starves the unlucky: under sustained load a few requests wait past every timeout while
// arrivals behind them are served, and the latency tail stops resembling the median at all.
gates.put(
profile.name(),
new Gate(profile, new Semaphore(profile.maxConcurrent(), true), new AtomicInteger()));
}
}
/** A controller over the three standard profiles. */
public static SemaphoreAdmissionController standard() {
return new SemaphoreAdmissionController(
AdmissionProfile.standard(),
AdmissionProfile.globalWrite(),
AdmissionProfile.expensiveQuery());
}
@Override
public AdmissionDecision admit(AdmissionProfileName profileName) {
Objects.requireNonNull(profileName, "profileName");
Gate gate = gates.get(profileName);
if (gate == null) {
throw new IllegalArgumentException(
"no admission profile named "
+ profileName
+ "; an operation whose profile is unregistered would run with no bound at all");
}
// Fast path: a free slot is taken without ever joining the queue, so an idle service adds no
// queue accounting to the common case.
if (gate.permits().tryAcquire()) {
return AdmissionDecision.admitted(permitFor(gate), Duration.ZERO);
}
int queueDepth = gate.queued().incrementAndGet();
try {
if (queueDepth > gate.profile().maxQueued()) {
return AdmissionDecision.refused(retryAfterFor(gate), Duration.ZERO);
}
long startedAt = System.nanoTime();
boolean acquired =
gate.permits().tryAcquire(gate.profile().maxQueueWait().toNanos(), TimeUnit.NANOSECONDS);
Duration waited = Duration.ofNanos(System.nanoTime() - startedAt);
return acquired
? AdmissionDecision.admitted(permitFor(gate), waited)
: AdmissionDecision.refused(retryAfterFor(gate), waited);
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
return AdmissionDecision.refused(retryAfterFor(gate), Duration.ZERO);
} finally {
gate.queued().decrementAndGet();
}
}
/** How many slots the profile currently has free. */
public int availablePermits(AdmissionProfileName profileName) {
Gate gate = gates.get(profileName);
return gate == null ? 0 : gate.permits().availablePermits();
}
private static AdmissionPermit permitFor(Gate gate) {
AtomicBoolean released = new AtomicBoolean();
return () -> {
// Guarded, because a double release hands back a slot that was never taken and quietly
// raises the concurrency bound above what the profile allows.
if (released.compareAndSet(false, true)) {
gate.permits().release();
}
};
}
private static Duration retryAfterFor(Gate gate) {
// The queue wait, not a fixed second: it is the timescale on which this profile actually
// drains, so it is the honest answer to "when should I come back".
return gate.profile().maxQueueWait().isZero()
? Duration.ofMillis(100)
: gate.profile().maxQueueWait();
}
}
@@ -0,0 +1,21 @@
package dev.caskeleton.adapter.inbound.web.admission;
import dev.caskeleton.adapter.inbound.web.operation.AdmissionProfileName;
/**
* Decides whether the service has room to run a request now.
*
* <p>Load shedding, not rate limiting. This looks only at what the service is currently doing and
* knows nothing about who is calling; the limiter looks only at the caller and knows nothing about
* the service's state. Keeping them apart is what lets a well-behaved caller be told 503 during a
* spike and an abusive one be told 429 while the service is idle.
*/
public interface WebAdmissionController {
/**
* Asks for a slot, waiting up to the profile's queue budget.
*
* @param profile which class of work this is
*/
AdmissionDecision admit(AdmissionProfileName profile);
}
@@ -0,0 +1,74 @@
package dev.caskeleton.adapter.inbound.web.advanced;
import java.util.Locale;
/**
* The web capabilities that are not part of Stable, each behind its own flag.
*
* <p>One flag per capability, not one for "advanced". They have nothing in common operationally:
* virtual threads change how every request is scheduled, streaming changes how long a response
* holds a connection, XML adds a parser with a decades-long history of entity-expansion attacks. A
* single switch would make those one decision, and a deployment that wanted the first would be
* given the third.
*
* <p>Every constant is off unless named.
*/
public enum WebAdvancedFeature {
/**
* A virtual-thread executor for MVC request handling.
*
* <p>Changes the scheduling model, not the concurrency budget. See {@code
* VirtualThreadAdmissionGuard} for why those are different things.
*/
MVC_VIRTUAL_THREADS,
/** A bounded, registered offload for blocking work called from WebFlux. */
WEBFLUX_BLOCKING_BRIDGE,
/** RFC 7396 {@code application/merge-patch+json}. */
JSON_MERGE_PATCH,
/** RFC 6902 {@code application/json-patch+json}. */
JSON_PATCH,
/** Server-sent events. */
SSE,
/** {@code application/x-ndjson} streaming. */
NDJSON,
/** RFC 7464 {@code application/json-seq} streaming. */
JSON_SEQUENCE,
/** Functional WebFlux routes, registered against the operation catalog. */
FUNCTIONAL_WEBFLUX,
/** {@code application/cbor} as a representation. */
CBOR,
/** {@code application/xml} as a representation. */
XML,
/** OpenAPI 3.2 generated alongside the Stable 3.1 snapshot. */
OPENAPI_32,
/** Draft {@code RateLimit} and {@code RateLimit-Policy} response headers. */
RATELIMIT_DRAFT_HEADERS;
/** The property that turns this on. */
public String propertyName() {
return "backend.web.advanced." + name().toLowerCase(Locale.ROOT).replace('_', '-') + ".enabled";
}
/**
* Whether enabling this changes behaviour for requests that do not use it.
*
* <p>The distinction that decides how much soak a capability needs. A codec only affects requests
* that negotiate it; a virtual-thread executor affects every request in the process, and the
* blocking bridge affects the event loop that every reactive request shares.
*/
public boolean affectsUnrelatedRequests() {
return this == MVC_VIRTUAL_THREADS || this == WEBFLUX_BLOCKING_BRIDGE;
}
}
@@ -0,0 +1,47 @@
package dev.caskeleton.adapter.inbound.web.advanced;
import java.util.EnumSet;
import java.util.Objects;
import java.util.Set;
/**
* Which Advanced capabilities this deployment named.
*
* <p>A value rather than a property lookup at each call site, so that "what is on" is one thing an
* operator can print rather than a set of conditions scattered through the configuration.
*/
public final class WebAdvancedFeatureFlags {
private final Set<WebAdvancedFeature> enabled;
private WebAdvancedFeatureFlags(Set<WebAdvancedFeature> enabled) {
this.enabled = Set.copyOf(enabled);
}
/** Nothing enabled. The default, and what a Stable deployment has. */
public static WebAdvancedFeatureFlags none() {
return new WebAdvancedFeatureFlags(EnumSet.noneOf(WebAdvancedFeature.class));
}
/** Exactly these. */
public static WebAdvancedFeatureFlags of(WebAdvancedFeature... features) {
Objects.requireNonNull(features, "features");
return new WebAdvancedFeatureFlags(Set.of(features));
}
/** Whether a capability is on. */
public boolean enabled(WebAdvancedFeature feature) {
Objects.requireNonNull(feature, "feature");
return enabled.contains(feature);
}
/** Everything that is on, for the startup report. */
public Set<WebAdvancedFeature> all() {
return enabled;
}
/** Whether this deployment behaves as a Stable one for requests that use no Advanced feature. */
public boolean stableBehaviourPreserved() {
return enabled.stream().noneMatch(WebAdvancedFeature::affectsUnrelatedRequests);
}
}
@@ -0,0 +1,97 @@
package dev.caskeleton.adapter.inbound.web.advanced.blockingbridge;
import java.time.Duration;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.LongAdder;
/**
* The bounded pool the bridge offloads into, and what it observed.
*
* <p>Separate from the bridge itself so the accounting can be asserted without a Reactor pipeline.
* The number that matters — the peak concurrency actually reached — is invisible from throughput
* and from latency; a bridge whose bound is not applied looks exactly like one whose bound is
* generous, right up until the pool is the heap.
*/
public final class BlockingBridgeBudget {
private static final String UNREGISTERED = "<unregistered>";
private final BlockingBridgeProfile profile;
private final Semaphore permits;
private final AtomicInteger inFlight = new AtomicInteger();
private final AtomicInteger peakConcurrency = new AtomicInteger();
private final Map<String, LongAdder> rejectionsByOperation = new ConcurrentHashMap<>();
public BlockingBridgeBudget(BlockingBridgeProfile profile) {
this.profile = Objects.requireNonNull(profile, "profile");
this.permits = new Semaphore(profile.maxConcurrency(), true);
}
/** The profile. */
public BlockingBridgeProfile profile() {
return profile;
}
/**
* Take a slot, or refuse.
*
* @param operation which registered operation
* @throws BlockingBridgeRejectedException if unregistered or no slot became free in time
*/
public void acquire(String operation) throws InterruptedException {
Objects.requireNonNull(operation, "operation");
if (!profile.registered(operation)) {
record(operation);
throw new BlockingBridgeRejectedException(
operation, BlockingBridgeRejectedException.Reason.NOT_REGISTERED);
}
Duration timeout = profile.queueTimeout();
if (!permits.tryAcquire(timeout.toMillis(), TimeUnit.MILLISECONDS)) {
record(operation);
throw new BlockingBridgeRejectedException(
operation, BlockingBridgeRejectedException.Reason.QUEUE_TIMEOUT);
}
int current = inFlight.incrementAndGet();
peakConcurrency.accumulateAndGet(current, Math::max);
}
/** Give the slot back. */
public void release() {
inFlight.decrementAndGet();
permits.release();
}
/** How many are running now. */
public int inFlight() {
return inFlight.get();
}
/** The most that ever ran at once. Must never exceed the configured bound. */
public int peakConcurrency() {
return peakConcurrency.get();
}
/** How many offloads this operation had refused. */
public long rejectionsFor(String operation) {
LongAdder counter = rejectionsByOperation.get(operation);
return counter == null ? 0L : counter.sum();
}
/** Refusals of operations nobody registered, under one key rather than one key each. */
public long unregisteredRejections() {
return rejectionsFor(UNREGISTERED);
}
private void record(String operation) {
// Only registered names become keys. An unregistered one is counted under a single sentinel,
// because the caller supplies that string and a map keyed on it grows with whatever is passed
// — which is the same unbounded-cardinality problem a metric tagged with client input has.
String key = profile.registered(operation) ? operation : UNREGISTERED;
rejectionsByOperation.computeIfAbsent(key, ignored -> new LongAdder()).increment();
}
}
@@ -0,0 +1,50 @@
package dev.caskeleton.adapter.inbound.web.advanced.blockingbridge;
import java.time.Duration;
import java.util.Objects;
import java.util.Set;
/**
* Which blocking operations may be offloaded, and how much of the offload pool each may use.
*
* <p>The registration is the point. Reactor's {@code boundedElastic()} is available from anywhere
* and unbounded in practice — it grows to ten times the CPU count *per scheduler* and queues
* without limit beyond that — so a controller that calls it has silently opted the whole
* application into an unbounded thread pool. Every such call site is invisible until the pool is
* the thing consuming the heap.
*
* <p>A registered set makes the offloads enumerable. The concurrency bound makes them survivable:
* without it, a slow dependency's callers accumulate on the pool and starve the fast ones, which
* looks like the fast dependency having failed.
*
* @param registeredOperations the operations permitted to offload
* @param maxConcurrency how many may run at once
* @param queueTimeout how long a caller waits for a slot before being refused
*/
public record BlockingBridgeProfile(
Set<String> registeredOperations, int maxConcurrency, Duration queueTimeout) {
public BlockingBridgeProfile {
registeredOperations =
Set.copyOf(Objects.requireNonNull(registeredOperations, "registeredOperations"));
Objects.requireNonNull(queueTimeout, "queueTimeout");
if (registeredOperations.isEmpty()) {
throw new IllegalArgumentException(
"a bridge with no registered operation refuses everything; if nothing blocks, do not "
+ "enable the bridge");
}
if (maxConcurrency < 1) {
throw new IllegalArgumentException("an offload pool of zero runs nothing");
}
if (queueTimeout.isNegative() || queueTimeout.isZero()) {
throw new IllegalArgumentException(
"an unbounded queue wait means a slow dependency's callers accumulate until the heap "
+ "does, and the fast dependencies starve behind them");
}
}
/** Whether an operation may use the bridge. */
public boolean registered(String operation) {
return operation != null && registeredOperations.contains(operation);
}
}
@@ -0,0 +1,47 @@
package dev.caskeleton.adapter.inbound.web.advanced.blockingbridge;
import java.util.Objects;
/**
* A blocking offload was refused.
*
* <p>Two reasons, kept apart because they mean different things to whoever is looking. An
* unregistered operation is a programming error found at runtime — somebody called the bridge from
* a path nobody declared. A full pool is a capacity signal.
*/
public final class BlockingBridgeRejectedException extends RuntimeException {
private static final long serialVersionUID = 1L;
private final transient String operation;
private final transient Reason reason;
public BlockingBridgeRejectedException(String operation, Reason reason) {
super("blocking offload refused for '" + operation + "': " + reason);
this.operation = Objects.requireNonNull(operation, "operation");
this.reason = Objects.requireNonNull(reason, "reason");
}
/** Which operation. */
public String operation() {
return operation;
}
/** Why. */
public Reason reason() {
return reason;
}
/** Why an offload was refused. */
public enum Reason {
/** Nobody declared this operation. A bug, not a capacity signal. */
NOT_REGISTERED,
/** The bounded pool is full. */
CAPACITY_EXHAUSTED,
/** No slot became available within the queue timeout. */
QUEUE_TIMEOUT
}
}
@@ -0,0 +1,61 @@
package dev.caskeleton.adapter.inbound.web.advanced.codec;
import java.util.Objects;
/**
* The decode limits that apply per representation.
*
* <p>Per representation rather than shared, because the same byte count means very different
* amounts of work in each. A megabyte of JSON is a megabyte of text to parse; a megabyte of CBOR
* can declare an array of a billion elements in a handful of bytes, and a megabyte of XML with a
* DTD can expand to whatever the entity nesting says. Applying the JSON body limit to all three
* bounds the bytes and not the work.
*
* @param representation which codec
* @param maxBodyBytes the ceiling on the encoded body
* @param maxNestingDepth how deeply structures may nest
* @param maxCollectionSize how many elements one array or object may hold
*/
public record CodecBudget(
WebRepresentation representation,
int maxBodyBytes,
int maxNestingDepth,
int maxCollectionSize) {
public CodecBudget {
Objects.requireNonNull(representation, "representation");
if (maxBodyBytes < 1) {
throw new IllegalArgumentException("a body ceiling of zero admits nothing");
}
if (maxNestingDepth < 1) {
throw new IllegalArgumentException(
"a depth limit is required: every one of these formats is recursive, and recursion "
+ "without a bound is a stack overflow that no exception handler can turn into a 400");
}
if (maxCollectionSize < 1) {
throw new IllegalArgumentException(
"a collection limit is required: a binary format declares a length before its contents, "
+ "so a few bytes can ask for an allocation the body size never bounded");
}
}
/** Conventional bounds: 1MB, 32 deep, 10,000 elements. */
public static CodecBudget conventional(WebRepresentation representation) {
return new CodecBudget(representation, 1_048_576, 32, 10_000);
}
/** Whether a body may be decoded at all. */
public boolean bodyWithinBounds(int bodyBytes) {
return bodyBytes >= 0 && bodyBytes <= maxBodyBytes;
}
/** Whether decoding may descend further. */
public boolean mayDescend(int currentDepth) {
return currentDepth < maxNestingDepth;
}
/** Whether a collection may grow further. */
public boolean mayGrow(int currentSize) {
return currentSize < maxCollectionSize;
}
}
@@ -0,0 +1,84 @@
package dev.caskeleton.adapter.inbound.web.advanced.codec;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
/**
* Which representation to answer with, given what the client asked for and what the route offers.
*
* <p>Three gates, all of which must agree, and the reason for three rather than one is that they
* fail differently. The feature flag says the codec exists in this deployment at all. The route's
* {@code produces} says this particular endpoint was designed to emit it — an endpoint whose
* response contains a field that only serializes sensibly as JSON should not be answering in CBOR
* because somebody sent an {@code Accept} header. The client allowlist says a caller was actually
* expected to use it.
*
* <p>Content negotiation alone is not enough, which is the thing that gets skipped: an {@code
* Accept: application/xml} header from an arbitrary client is not evidence that the route was ever
* tested against the XML codec.
*/
public final class RepresentationNegotiationPolicy {
private final Set<WebRepresentation> enabledRepresentations;
private final Set<String> allowedClients;
/**
* @param enabledRepresentations what this deployment turned on
* @param allowedClients which client identifiers may negotiate a non-JSON representation
*/
public RepresentationNegotiationPolicy(
Set<WebRepresentation> enabledRepresentations, Set<String> allowedClients) {
this.enabledRepresentations =
Set.copyOf(Objects.requireNonNull(enabledRepresentations, "enabledRepresentations"));
this.allowedClients = Set.copyOf(Objects.requireNonNull(allowedClients, "allowedClients"));
}
/** JSON only, which is what a Stable deployment has. */
public static RepresentationNegotiationPolicy jsonOnly() {
return new RepresentationNegotiationPolicy(Set.of(WebRepresentation.JSON), Set.of());
}
/**
* Choose a representation.
*
* @param accepted the client's ordered preferences
* @param routeProduces what the route declares it can emit
* @param clientId who is asking, absent for an unidentified caller
* @return the chosen representation, empty when nothing acceptable is available
*/
public Optional<WebRepresentation> negotiate(
List<String> accepted, Set<WebRepresentation> routeProduces, Optional<String> clientId) {
Objects.requireNonNull(accepted, "accepted");
Objects.requireNonNull(routeProduces, "routeProduces");
Objects.requireNonNull(clientId, "clientId");
for (String candidate : accepted) {
Optional<WebRepresentation> resolved = WebRepresentation.fromMediaType(candidate);
if (resolved.isEmpty()) {
continue;
}
WebRepresentation representation = resolved.get();
if (permitted(representation, routeProduces, clientId)) {
return Optional.of(representation);
}
}
// No fallback to JSON. A client that asked only for CBOR and gets JSON receives bytes it will
// try to parse as CBOR, which fails somewhere far from here; 406 says what happened.
return Optional.empty();
}
private boolean permitted(
WebRepresentation representation,
Set<WebRepresentation> routeProduces,
Optional<String> clientId) {
if (!routeProduces.contains(representation)
|| !enabledRepresentations.contains(representation)) {
return false;
}
if (!representation.requiresOptIn()) {
return true;
}
return clientId.filter(allowedClients::contains).isPresent();
}
}
@@ -0,0 +1,59 @@
package dev.caskeleton.adapter.inbound.web.advanced.codec;
import javax.xml.stream.XMLInputFactory;
import javax.xml.stream.XMLResolver;
import javax.xml.stream.XMLStreamException;
/**
* An {@link XMLInputFactory} with the two features that make XML dangerous turned off.
*
* <p>Both defaults are on, and both are old enough that "everyone knows" — which is exactly why
* they keep shipping. Neither produces an error when it fires; the parse succeeds and the document
* contains something it should not.
*
* <p>{@code SUPPORT_DTD=false} disables the DTD subset entirely. That stops the billion-laughs
* expansion, where ten nested internal entities each referencing the previous ten expand a
* two-hundred-byte document into gigabytes of heap — before any application code sees it, and
* without a single external request.
*
* <p>{@code isSupportingExternalEntities=false} plus a throwing resolver stops XXE: an entity
* declared {@code SYSTEM "file:///etc/passwd"} is resolved by the parser and its content
* substituted into the document, so a field in the resulting DTO holds the file. The resolver is
* belt and braces — the property alone is enough on a conformant implementation, and the throwing
* resolver means a non-conformant one fails loudly instead of reading the file.
*/
public final class SecureXmlInputFactory {
private SecureXmlInputFactory() {}
/** A factory that cannot be talked into reading the filesystem or expanding entities. */
public static XMLInputFactory create() {
XMLInputFactory factory = XMLInputFactory.newFactory();
factory.setProperty(XMLInputFactory.SUPPORT_DTD, false);
factory.setProperty("javax.xml.stream.isSupportingExternalEntities", false);
factory.setXMLResolver(refusingResolver());
return factory;
}
/** A resolver that refuses rather than resolves. */
public static XMLResolver refusingResolver() {
return (publicId, systemId, baseUri, namespace) -> {
throw new XMLStreamException(
"external entity resolution is disabled; an entity that resolves is a file read or an "
+ "outbound request performed by the parser on the sender's behalf");
};
}
/**
* Whether a factory is configured safely.
*
* <p>Exists so a test can assert on a factory the application built, rather than on one the test
* built. The failure this guards is a configuration path that constructs its own factory and
* never reaches {@link #create()}.
*/
public static boolean secure(XMLInputFactory factory) {
return Boolean.FALSE.equals(factory.getProperty(XMLInputFactory.SUPPORT_DTD))
&& Boolean.FALSE.equals(
factory.getProperty("javax.xml.stream.isSupportingExternalEntities"));
}
}
@@ -0,0 +1,87 @@
package dev.caskeleton.adapter.inbound.web.advanced.codec;
import java.util.Objects;
import tools.jackson.core.StreamReadConstraints;
import tools.jackson.databind.DeserializationFeature;
import tools.jackson.databind.MapperFeature;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.cfg.CoercionAction;
import tools.jackson.databind.cfg.CoercionInputShape;
import tools.jackson.databind.type.LogicalType;
import tools.jackson.dataformat.cbor.CBORFactory;
import tools.jackson.dataformat.cbor.CBORMapper;
/**
* The CBOR mapper, configured to be exactly as strict as the JSON one and no looser.
*
* <p>That equivalence is the whole requirement, and it is easy to lose. A binary codec is usually
* added for compactness, and the natural way to add one is to build a mapper with its defaults —
* which are looser than this application's JSON profile in two specific ways. Unknown properties
* are ignored, so a client that misspells a field gets a silent default instead of a 400. And
* scalars coerce, so the string {@code "5"} becomes the number 5 in a field the JSON profile would
* have refused.
*
* <p>The result is a representation that accepts requests the primary one rejects — which is a
* validation bypass reachable by changing one header.
*
* <p>The stream constraints are separate from the JSON ones and stricter in the dimension that
* matters. CBOR declares a collection's length before its contents, so a handful of bytes can ask
* the decoder for an enormous allocation; the body-size limit that bounds JSON bounds the bytes and
* not the work.
*/
public final class WebCborMapperFactory {
private WebCborMapperFactory() {}
/**
* A mapper matching the JSON profile's strictness.
*
* @param budget supplies the decode bounds this representation runs under
*/
public static ObjectMapper create(CodecBudget budget) {
Objects.requireNonNull(budget, "budget");
if (budget.representation() != WebRepresentation.CBOR) {
throw new IllegalArgumentException(
"a CBOR mapper built from another representation's budget would enforce the wrong"
+ " limits: "
+ budget.representation());
}
requireBackend();
CBORFactory factory =
CBORFactory.builder()
.streamReadConstraints(
StreamReadConstraints.builder()
.maxNestingDepth(budget.maxNestingDepth())
.maxDocumentLength(budget.maxBodyBytes())
.build())
.build();
return CBORMapper.builder(factory)
// The three that make this the JSON profile rather than Jackson's defaults. Without the
// first, a misspelled field is silently ignored; without the other two, "5" becomes 5 in a
// numeric field and "true" becomes true in a boolean one.
.enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
.disable(MapperFeature.ALLOW_COERCION_OF_SCALARS)
.withCoercionConfigDefaults(
config -> config.setCoercion(CoercionInputShape.String, CoercionAction.Fail))
.withCoercionConfig(
LogicalType.Textual,
config -> config.setCoercion(CoercionInputShape.Integer, CoercionAction.Fail))
.build();
}
/**
* Refuse with a sentence rather than a {@code NoClassDefFoundError} naming a Jackson class.
*
* <p>The backend is compile-only, so this is the state a deployment reaches by enabling the
* capability and not adding the jar. The error it would otherwise get comes from inside a mapper
* builder and says nothing about the decision that caused it.
*/
private static void requireBackend() {
if (!WebRepresentation.CBOR.available()) {
throw new IllegalStateException(
"the CBOR capability is enabled but tools.jackson.dataformat:jackson-dataformat-cbor is"
+ " not on the runtime classpath; it is compile-only here so that enabling it stays"
+ " a deployment's decision rather than every deployment's default");
}
}
}
@@ -0,0 +1,90 @@
package dev.caskeleton.adapter.inbound.web.advanced.codec;
import java.util.Locale;
import java.util.Optional;
/**
* The representations a route may produce beyond JSON.
*
* <p>An enum rather than free media-type strings, because each of these is a parser and adding one
* is a security decision, not a formatting preference. The set being closed is what makes "which
* parsers can a request reach" a question with an answer.
*/
public enum WebRepresentation {
/** The Stable representation. Always available. */
JSON("application/json"),
/** Compact binary. Same DTOs, same validation, a second decoder. */
CBOR("application/cbor"),
/**
* XML.
*
* <p>The one with a genuinely dangerous default. An {@code XMLInputFactory} out of the box
* resolves external entities and expands internal ones, which is XXE and the billion-laughs
* expansion respectively — file disclosure and a memory exhaustion from a two-line document.
*/
XML("application/xml");
private final String mediaType;
WebRepresentation(String mediaType) {
this.mediaType = mediaType;
}
/** The media type. */
public String mediaType() {
return mediaType;
}
/** Whether this representation needs an explicit opt-in. */
public boolean requiresOptIn() {
return this != JSON;
}
/**
* Whether this representation's format backend is on the runtime classpath.
*
* <p>CBOR and XML are compile-only here, so a deployment that enables one of them adds the jar.
* That is deliberate — putting either on every deployment's runtime classpath makes Spring Boot
* register a mapper bean for it, and for XML a message converter too, which turns a capability
* that is off by default into a parser every request can reach.
*
* <p>The cost of that choice is this method. Without it the missing jar surfaces as a {@code
* NoClassDefFoundError} from inside a mapper builder, which names a Jackson class and not the
* decision that caused it.
*/
public boolean available() {
return switch (this) {
case JSON -> true;
case CBOR -> classPresent("tools.jackson.dataformat.cbor.CBORMapper");
case XML -> classPresent("tools.jackson.dataformat.xml.XmlMapper");
};
}
private static boolean classPresent(String className) {
try {
Class.forName(className, false, WebRepresentation.class.getClassLoader());
return true;
} catch (ClassNotFoundException absent) {
return false;
}
}
/** Resolve a media type. Empty for anything else, which the caller answers with a 406. */
public static Optional<WebRepresentation> fromMediaType(String mediaType) {
if (mediaType == null) {
return Optional.empty();
}
String normalized = mediaType.trim().toLowerCase(Locale.ROOT);
int parameters = normalized.indexOf(';');
String base = parameters < 0 ? normalized : normalized.substring(0, parameters).trim();
for (WebRepresentation representation : values()) {
if (representation.mediaType.equals(base)) {
return Optional.of(representation);
}
}
return Optional.empty();
}
}
@@ -0,0 +1,99 @@
package dev.caskeleton.adapter.inbound.web.advanced.codec;
import java.util.Objects;
import javax.xml.stream.XMLInputFactory;
import tools.jackson.core.StreamReadConstraints;
import tools.jackson.databind.DeserializationFeature;
import tools.jackson.databind.MapperFeature;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.dataformat.xml.XmlFactory;
import tools.jackson.dataformat.xml.XmlMapper;
/**
* The XML mapper, built on an input factory that cannot read the filesystem.
*
* <p>The hardening is not applied here — it comes from {@link SecureXmlInputFactory}, and this
* factory refuses to build a mapper on anything else. That refusal is the point. Jackson's XML
* module will happily accept a default {@code XMLInputFactory}, and a default one resolves external
* entities and expands internal ones: XXE and the billion-laughs expansion respectively, neither of
* which produces an error when it fires. The parse succeeds and the resulting object holds the
* contents of a file, or the heap is gone.
*
* <p>Checking rather than only configuring matters because there are two ways to get a mapper:
* through this method, or through some other configuration path that builds its own factory. The
* second is the one that ships the vulnerability, and the check is what makes it fail loudly.
*
* <p>Strictness otherwise matches the JSON profile, for the reason the CBOR mapper does: a
* representation that accepts what the primary one rejects is a validation bypass reachable by
* changing one header.
*/
public final class WebXmlMapperFactory {
private WebXmlMapperFactory() {}
/**
* A mapper on a hardened input factory.
*
* @param budget supplies the decode bounds this representation runs under
*/
public static ObjectMapper create(CodecBudget budget) {
Objects.requireNonNull(budget, "budget");
if (budget.representation() != WebRepresentation.XML) {
throw new IllegalArgumentException(
"an XML mapper built from another representation's budget would enforce the wrong"
+ " limits: "
+ budget.representation());
}
requireBackend();
return create(budget, SecureXmlInputFactory.create());
}
/**
* A mapper on a caller-supplied input factory, which must already be hardened.
*
* @throws IllegalArgumentException when the factory would resolve entities or read a DTD
*/
public static ObjectMapper create(CodecBudget budget, XMLInputFactory input) {
Objects.requireNonNull(budget, "budget");
Objects.requireNonNull(input, "input");
if (!SecureXmlInputFactory.secure(input)) {
// Refused rather than silently re-hardened. A caller that passed an unsafe factory has
// another code path that builds one, and quietly fixing this instance leaves that path
// shipping the vulnerability.
throw new IllegalArgumentException(
"the supplied XMLInputFactory resolves external entities or reads a DTD; build it with"
+ " SecureXmlInputFactory.create() rather than hardening it here, so the path that"
+ " produced this one is the thing that gets fixed");
}
XmlFactory factory =
XmlFactory.builder()
.xmlInputFactory(input)
.streamReadConstraints(
StreamReadConstraints.builder()
.maxNestingDepth(budget.maxNestingDepth())
.maxDocumentLength(budget.maxBodyBytes())
.build())
.build();
return XmlMapper.builder(factory)
.enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
.disable(MapperFeature.ALLOW_COERCION_OF_SCALARS)
.build();
}
/**
* Refuse with a sentence rather than a {@code NoClassDefFoundError} naming a Jackson class.
*
* <p>The backend is compile-only here for a reason specific to this format: putting
* jackson-dataformat-xml on the runtime classpath makes Spring register an XML message converter,
* so every deployment starts parsing {@code application/xml} request bodies whether or not it
* enabled the capability — an XXE surface acquired by adding a dependency.
*/
private static void requireBackend() {
if (!WebRepresentation.XML.available()) {
throw new IllegalStateException(
"the XML capability is enabled but tools.jackson.dataformat:jackson-dataformat-xml is not"
+ " on the runtime classpath; it is compile-only here because its presence alone"
+ " makes Spring accept application/xml request bodies everywhere");
}
}
}
@@ -0,0 +1,53 @@
package dev.caskeleton.adapter.inbound.web.advanced.functional;
import dev.caskeleton.adapter.inbound.web.operation.WebOperationCatalog;
import dev.caskeleton.adapter.inbound.web.operation.WebOperationProfile;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
/**
* Checks that a functional route carries the same guarantees an annotated controller would.
*
* <p>The catalog lookup does most of the work — it throws for an unregistered operation — but two
* things it cannot check are checked here, because they are properties of the *route* rather than
* of the operation.
*
* <p>The method has to match. A catalog entry declares an operation as, say, a mutation with an
* idempotency policy; registering it behind a GET gives a read the write's policy and, worse, gives
* a write no read caching restrictions. Nothing downstream re-derives the method from the route.
*/
public final class FunctionalRoutePolicyValidator {
private final WebOperationCatalog catalog;
public FunctionalRoutePolicyValidator(WebOperationCatalog catalog) {
this.catalog = Objects.requireNonNull(catalog, "catalog");
}
/**
* Validate a route.
*
* @throws IllegalStateException listing everything wrong with it
*/
public void validate(RegisteredRoute route) {
Objects.requireNonNull(route, "route");
// Throws for an unregistered operation, which is the check that matters most and the one a raw
// RouterFunction bean skips entirely.
WebOperationProfile profile = catalog.require(route.operationName());
List<String> faults = new ArrayList<>();
if (profile.method() != route.method()) {
faults.add(
"the route is a "
+ route.method()
+ " but the operation is declared as a "
+ profile.method()
+ "; the profile's idempotency, precondition and cache policies were chosen for the "
+ "declared method and nothing downstream re-derives it from the route");
}
if (!faults.isEmpty()) {
throw new IllegalStateException(
"functional route " + route.routeKey() + " does not match its operation: " + faults);
}
}
}
@@ -0,0 +1,60 @@
package dev.caskeleton.adapter.inbound.web.advanced.functional;
import dev.caskeleton.adapter.inbound.web.operation.WebOperationCatalog;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
* The functional routes this deployment serves.
*
* <p>Every route goes through {@link #register}, and {@link #register} goes through the operation
* catalog. That is the whole design: a functional route that skipped this would be a {@code
* RouterFunction} bean, which Spring picks up and serves with no profile at all.
*
* <p>Duplicate detection is here rather than left to the router. Spring's {@code RouterFunctions}
* resolves the first match, so two registrations for the same method and pattern give one of them
* silently — and which one depends on registration order, which depends on bean order.
*/
public final class FunctionalRouteRegistry {
private final FunctionalRoutePolicyValidator validator;
private final Map<String, RegisteredRoute> routes = new LinkedHashMap<>();
public FunctionalRouteRegistry(WebOperationCatalog catalog) {
this.validator = new FunctionalRoutePolicyValidator(Objects.requireNonNull(catalog, "catalog"));
}
/**
* Register a route.
*
* @throws IllegalStateException if its operation is unregistered, its method disagrees with the
* operation, or the same method and pattern are already claimed
*/
public void register(RegisteredRoute route) {
Objects.requireNonNull(route, "route");
validator.validate(route);
RegisteredRoute previous = routes.putIfAbsent(route.routeKey(), route);
if (previous != null) {
throw new IllegalStateException(
"two functional routes claim "
+ route.routeKey()
+ "; the router resolves the first match, so which one serves depends on bean "
+ "ordering: "
+ previous.handlerName()
+ " and "
+ route.handlerName());
}
}
/** Every registered route, in registration order. */
public List<RegisteredRoute> routes() {
return List.copyOf(routes.values());
}
/** How many are registered. */
public int size() {
return routes.size();
}
}
@@ -0,0 +1,45 @@
package dev.caskeleton.adapter.inbound.web.advanced.functional;
import dev.caskeleton.adapter.inbound.web.core.WebOperationName;
import dev.caskeleton.adapter.inbound.web.operation.HttpMethodSemantic;
import java.util.Objects;
/**
* One functional route, tied to a catalog operation.
*
* <p>The operation name is not optional and not derived. A functional route is a lambda registered
* against a path — there is no annotation for a scanner to find and no class name to infer from, so
* a route without a declared operation has no budget, no authorization profile, no idempotency
* policy and no cache policy, and nothing anywhere will say so. It will simply serve.
*
* <p>That is the entire difference between the annotated and functional styles as far as this leaf
* is concerned, and it is why {@code RouterFunction} beans are not registered directly.
*
* @param pattern the path pattern
* @param method the HTTP method
* @param operationName the catalog operation this route serves
* @param handlerName a stable name for logs and metrics
*/
public record RegisteredRoute(
String pattern, HttpMethodSemantic method, WebOperationName operationName, String handlerName) {
public RegisteredRoute {
Objects.requireNonNull(pattern, "pattern");
Objects.requireNonNull(method, "method");
Objects.requireNonNull(operationName, "operationName");
Objects.requireNonNull(handlerName, "handlerName");
if (!pattern.startsWith("/")) {
throw new IllegalArgumentException("a route pattern is an absolute path: " + pattern);
}
if (handlerName.isBlank()) {
throw new IllegalArgumentException(
"a functional handler is a lambda, so it has no class name to fall back on in a log; "
+ "the name has to be given");
}
}
/** The route's identity for duplicate detection. */
public String routeKey() {
return method.name() + " " + pattern;
}
}
@@ -0,0 +1,102 @@
package dev.caskeleton.adapter.inbound.web.advanced.functional;
import dev.caskeleton.adapter.inbound.web.operation.HttpMethodSemantic;
import java.util.List;
import java.util.Objects;
import java.util.function.Function;
import org.springframework.http.HttpMethod;
import org.springframework.web.reactive.function.server.HandlerFunction;
import org.springframework.web.reactive.function.server.RequestPredicate;
import org.springframework.web.reactive.function.server.RequestPredicates;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.RouterFunctions;
import org.springframework.web.reactive.function.server.ServerResponse;
/**
* Builds the {@link RouterFunction} from routes the registry has already validated.
*
* <p>Deliberately the only way a functional route reaches Spring in this leaf. A {@code
* RouterFunction} bean is picked up and served with no operation profile at all — no budget, no
* authorization profile, no idempotency policy — and nothing anywhere reports it. Building the
* router here, from the registry, means every route has been through {@link
* FunctionalRoutePolicyValidator} before it can be reachable.
*
* <p>Routes are composed in registration order and the registry has already refused duplicates.
* That ordering matters: {@code RouterFunctions} resolves the first match, so two routes for one
* method and pattern would make bean ordering decide which one serves.
*/
public final class WebFunctionalHandlerAdapter {
private final FunctionalRouteRegistry registry;
private final Function<RegisteredRoute, HandlerFunction<ServerResponse>> handlers;
/**
* @param registry the validated routes
* @param handlers resolves a route to the handler that serves it
*/
public WebFunctionalHandlerAdapter(
FunctionalRouteRegistry registry,
Function<RegisteredRoute, HandlerFunction<ServerResponse>> handlers) {
this.registry = Objects.requireNonNull(registry, "registry");
this.handlers = Objects.requireNonNull(handlers, "handlers");
}
/**
* The composed router.
*
* @throws IllegalStateException when no route is registered, or a route has no handler
*/
public RouterFunction<ServerResponse> build() {
List<RegisteredRoute> routes = registry.routes();
if (routes.isEmpty()) {
// Refused rather than returning an empty router. An empty one is a bean that serves nothing
// and looks installed, which is indistinguishable from a registry nobody populated.
throw new IllegalStateException(
"no functional route is registered; an empty router is a bean that serves nothing and"
+ " looks installed");
}
RouterFunction<ServerResponse> composed = null;
for (RegisteredRoute route : routes) {
RouterFunction<ServerResponse> single =
RouterFunctions.route(predicate(route), handler(route));
composed = composed == null ? single : composed.and(single);
}
return composed;
}
private HandlerFunction<ServerResponse> handler(RegisteredRoute route) {
HandlerFunction<ServerResponse> handler = handlers.apply(route);
if (handler == null) {
// A route with no handler would be a 404 that looks like a routing bug rather than a wiring
// one, and it would only appear when somebody called it.
throw new IllegalStateException(
"functional route "
+ route.routeKey()
+ " has no handler; '"
+ route.handlerName()
+ "' resolved to nothing");
}
return handler;
}
private static RequestPredicate predicate(RegisteredRoute route) {
return RequestPredicates.method(httpMethod(route.method()))
.and(RequestPredicates.path(route.pattern()));
}
private static HttpMethod httpMethod(HttpMethodSemantic method) {
// An exhaustive switch rather than a name lookup or a map. The catalog's method vocabulary and
// Spring's are separate enums, so valueOf() across them turns adding a member to one into a
// runtime failure in the other — and a map with a computed default evaluates that default
// eagerly, which makes every lookup throw.
return switch (method) {
case GET -> HttpMethod.GET;
case HEAD -> HttpMethod.HEAD;
case OPTIONS -> HttpMethod.OPTIONS;
case POST -> HttpMethod.POST;
case PUT -> HttpMethod.PUT;
case PATCH -> HttpMethod.PATCH;
case DELETE -> HttpMethod.DELETE;
};
}
}
@@ -0,0 +1,86 @@
package dev.caskeleton.adapter.inbound.web.advanced.mvc;
import dev.caskeleton.adapter.inbound.web.advanced.stream.WebStreamTermination;
import java.io.IOException;
import java.util.Objects;
import java.util.Optional;
/**
* Decides what a failed write to a servlet stream means.
*
* <p>On the servlet side there is no disconnect event. The container does not tell the application
* that a client went away; the first the application knows is that a write throws. So the heartbeat
* is not a keepalive for the client's benefit — it is the *probe* that produces that throw on a
* stream that would otherwise sit silent for an hour holding a thread, a buffer and whatever its
* source is subscribed to.
*
* <p>Which is why a heartbeat write failure is recorded as disconnect evidence rather than as an
* error. It is the mechanism working.
*
* <p>The classification matters because the two cases go to different places. A client that closed
* its browser tab is {@link WebStreamTermination#CLIENT_DISCONNECTED} and is not a fault; anything
* else is {@link WebStreamTermination#ABRUPT_CLOSE} and is.
*/
public final class MvcDisconnectDetector {
private MvcDisconnectDetector() {}
/**
* Classify a write failure.
*
* @param failure what the write threw
*/
public static WebStreamTermination classify(Throwable failure) {
Objects.requireNonNull(failure, "failure");
return clientWentAway(failure)
? WebStreamTermination.CLIENT_DISCONNECTED
: WebStreamTermination.ABRUPT_CLOSE;
}
/**
* Whether a failure means the client went away rather than something breaking.
*
* <p>Matched on the exception chain rather than on a message, with one exception: the containers
* differ on what they throw for a closed peer and several of them use a plain {@code IOException}
* whose only distinguishing feature is its text. Tomcat's is "Broken pipe", Jetty's is
* "EofException", Undertow closes the channel. Matching text is fragile and matching nothing is
* worse — every client disconnect would be logged as a server fault, and the fault rate would be
* whatever the tab-closing rate is.
*/
public static boolean clientWentAway(Throwable failure) {
for (Throwable current = failure; current != null; current = current.getCause()) {
if (current instanceof java.io.EOFException) {
return true;
}
if (current.getClass().getSimpleName().equals("EofException")) {
return true;
}
if (current instanceof IOException && brokenPipe(current.getMessage())) {
return true;
}
if (current.getCause() == current) {
break;
}
}
return false;
}
/** The message a heartbeat failure should carry, if any. */
public static Optional<String> evidenceNote(Throwable failure) {
if (clientWentAway(failure)) {
return Optional.of("the heartbeat write failed because the client is gone");
}
return Optional.empty();
}
private static boolean brokenPipe(String message) {
if (message == null) {
return false;
}
String lower = message.toLowerCase(java.util.Locale.ROOT);
return lower.contains("broken pipe")
|| lower.contains("connection reset")
|| lower.contains("connection was aborted")
|| lower.contains("an established connection was aborted");
}
}
@@ -0,0 +1,117 @@
package dev.caskeleton.adapter.inbound.web.advanced.mvc;
import dev.caskeleton.adapter.inbound.web.advanced.stream.StreamSequence;
import dev.caskeleton.adapter.inbound.web.advanced.stream.WebStreamEvidence;
import dev.caskeleton.adapter.inbound.web.advanced.stream.WebStreamTermination;
import dev.caskeleton.adapter.inbound.web.advanced.stream.encoding.NdjsonRecord;
import dev.caskeleton.adapter.inbound.web.advanced.stream.encoding.StreamFraming;
import dev.caskeleton.adapter.inbound.web.advanced.stream.encoding.StreamRecordEncoder;
import java.io.IOException;
import java.io.OutputStream;
import java.time.Instant;
import java.util.Iterator;
import java.util.Objects;
/**
* Writes an NDJSON or JSON-seq response on the servlet stack.
*
* <p>One writer for both, parameterised by framing, because everything else about them is the same
* and the parts that matter are the parts that are the same: flush after every record, write the
* terminal record before closing, and classify a write failure rather than letting it become a 500
* on a response that is already 200.
*
* <p>**Flushing after every record is not optional.** Without it the container buffers, and a
* streaming response that arrives in one chunk at the end is a slow non-streaming response — which
* passes every test that checks the body and fails the only thing the client wanted.
*/
public final class MvcStreamWriter {
private final StreamRecordEncoder encoder;
private final StreamFraming framing;
public MvcStreamWriter(StreamRecordEncoder encoder, StreamFraming framing) {
this.encoder = Objects.requireNonNull(encoder, "encoder");
this.framing = Objects.requireNonNull(framing, "framing");
}
/** The media type to set on the response. */
public String mediaType() {
return framing.mediaType();
}
/**
* Write a whole stream and its terminal record.
*
* @param records the source
* @param output the response body
* @param evidence what actually got written
* @return how the stream ended
*/
public <T> WebStreamTermination write(
Iterator<NdjsonRecord<T>> records, OutputStream output, WebStreamEvidence evidence) {
Objects.requireNonNull(records, "records");
Objects.requireNonNull(output, "output");
Objects.requireNonNull(evidence, "evidence");
long lastSequence = 0;
try {
while (records.hasNext()) {
NdjsonRecord<T> record = records.next();
writeRecord(record, output);
if (record instanceof NdjsonRecord.Item<T> item) {
evidence.recordDelivered(new StreamSequence(item.sequence()));
lastSequence = item.sequence();
}
}
writeRecord(NdjsonRecord.<T>complete(lastSequence), output);
evidence.recordTermination(WebStreamTermination.NORMAL_COMPLETE, Instant.now());
return WebStreamTermination.NORMAL_COMPLETE;
} catch (IOException failure) {
WebStreamTermination how = MvcDisconnectDetector.classify(failure);
evidence.recordTermination(how, Instant.now());
return how;
} catch (RuntimeException failure) {
return terminate(failure, output, evidence, lastSequence);
}
}
private <T> WebStreamTermination terminate(
RuntimeException failure,
OutputStream output,
WebStreamEvidence evidence,
long lastSequence) {
// A client disconnect can reach here wrapped in a RuntimeException — the containers are not
// consistent about which layer wraps it. Writing a terminal record to a socket that is already
// gone would then be counted as a server fault, and the fault rate would track the
// tab-closing rate.
if (MvcDisconnectDetector.clientWentAway(failure)) {
evidence.recordTermination(WebStreamTermination.CLIENT_DISCONNECTED, Instant.now());
return WebStreamTermination.CLIENT_DISCONNECTED;
}
// Otherwise the source failed after commit. The status is already 200, so the failure goes
// into the stream as a terminal record — and if even that cannot be written, it is an abrupt
// close and is recorded as one rather than counted as a completion.
try {
writeRecord(
new NdjsonRecord.Failure<T>(
lastSequence,
"DEPENDENCY_FAILURE",
"the stream ended early because a dependency failed"),
output);
evidence.recordTermination(WebStreamTermination.TERMINAL_ERROR_RECORD, Instant.now());
return WebStreamTermination.TERMINAL_ERROR_RECORD;
} catch (IOException | RuntimeException unwritable) {
Objects.requireNonNull(unwritable);
evidence.recordTermination(WebStreamTermination.ABRUPT_CLOSE, Instant.now());
return WebStreamTermination.ABRUPT_CLOSE;
}
}
private void writeRecord(Object record, OutputStream output) throws IOException {
byte[] bytes =
framing == StreamFraming.NDJSON ? encoder.ndjson(record) : encoder.jsonSequence(record);
output.write(bytes);
// Per record. A buffered streaming response arrives in one chunk at the end, which passes a
// body assertion and fails the only property the client wanted.
output.flush();
}
}
@@ -0,0 +1,86 @@
package dev.caskeleton.adapter.inbound.web.advanced.mvc;
import dev.caskeleton.adapter.inbound.web.advanced.stream.WebStreamPolicy;
import dev.caskeleton.adapter.inbound.web.advanced.stream.WebStreamRegistry;
import dev.caskeleton.adapter.inbound.web.advanced.stream.encoding.StreamFraming;
import dev.caskeleton.adapter.inbound.web.advanced.stream.encoding.StreamRecordEncoder;
import dev.caskeleton.adapter.inbound.web.json.WebObjectMapperFactory;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Wires servlet-side record streaming: NDJSON and RFC 7464 JSON text sequences.
*
* <p><strong>No server-sent events here, and not by oversight.</strong> This repository's {@code
* feature-streaming-response-contract} D3 refuses SSE on the servlet stack, and {@code
* CleanArchitectureTest.NO_SSE_EMITTER} / {@code NO_RESPONSE_BODY_EMITTER} enforce it: production
* code may not name {@code SseEmitter} or {@code ResponseBodyEmitter}. The same decision explicitly
* permits {@code StreamingResponseBody}, which is the shape {@link MvcStreamWriter} writes — a
* response body produced incrementally, in the request-response model, rather than a server-push
* channel.
*
* <p>The design package this leaf implements asks for an MVC SSE adapter. That request loses to the
* repository's own recorded decision, which is what {@code AGENTS.md} says happens when the two
* disagree. The reactive stack keeps its SSE adapter, where no such rule applies.
*
* <p>What survives from the SSE design is the part that was never SSE-specific: a bounded registry
* of open streams, a per-item encoder that enforces the size ceiling before framing, and a
* disconnect classifier — because on the servlet stack a failed write is still the only signal that
* a client has gone.
*/
@Configuration(proxyBeanMethods = false)
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)
@ConditionalOnProperty(
prefix = "backend.web.advanced.ndjson",
name = "enabled",
havingValue = "true")
public class MvcStreamingExecutorConfiguration {
/** The bounds every stream on this node runs under. */
@Bean
@ConditionalOnMissingBean
public WebStreamPolicy webStreamPolicy() {
return WebStreamPolicy.conventional();
}
/**
* The node's stream ceiling.
*
* <p>Sized independently of the request thread pool: a stream is a request that never finishes,
* so the pool bounds arrivals and this bounds residents.
*/
@Bean
@ConditionalOnMissingBean
public WebStreamRegistry webStreamRegistry() {
return new WebStreamRegistry(1_000);
}
/** Serializes stream records, and enforces the per-item ceiling before framing. */
@Bean
@ConditionalOnMissingBean
public StreamRecordEncoder streamRecordEncoder(WebStreamPolicy policy) {
return new StreamRecordEncoder(WebObjectMapperFactory.standard(), policy);
}
/** The NDJSON writer. */
@Bean
@ConditionalOnMissingBean(name = "mvcNdjsonWriter")
public MvcStreamWriter mvcNdjsonWriter(StreamRecordEncoder encoder) {
return new MvcStreamWriter(encoder, StreamFraming.NDJSON);
}
/**
* The JSON text sequence writer.
*
* <p>A separate bean rather than a parameter on one, because the framing is a property of the
* route's media type and a route wires the writer that matches what it declares it produces.
*/
@Bean
@ConditionalOnMissingBean(name = "mvcJsonSequenceWriter")
public MvcStreamWriter mvcJsonSequenceWriter(StreamRecordEncoder encoder) {
return new MvcStreamWriter(encoder, StreamFraming.JSON_SEQUENCE);
}
}
@@ -0,0 +1,77 @@
package dev.caskeleton.adapter.inbound.web.advanced.mvc;
import dev.caskeleton.adapter.inbound.web.advanced.virtualthread.VirtualThreadAdmissionGuard;
import dev.caskeleton.adapter.inbound.web.advanced.virtualthread.VirtualThreadProfile;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.task.AsyncTaskExecutor;
import org.springframework.core.task.TaskDecorator;
import org.springframework.core.task.support.TaskExecutorAdapter;
/**
* Wires the virtual-thread executor and, in the same configuration, the admission limit.
*
* <p>The two are one bean definition set on purpose. A deployment that got the executor without the
* limit would have deleted its implicit concurrency bound and replaced it with nothing — and it
* would look fine until the arrival rate exceeded what the database pool serves, at which point
* every request times out having done no work.
*
* <p>{@link VirtualThreadSettings} refuses to bind an enabled profile without a limit, so that
* combination cannot be configured. This configuration then cannot produce the executor without
* also producing the guard, because both are in it.
*/
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(VirtualThreadSettings.class)
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)
@ConditionalOnProperty(
prefix = "backend.web.advanced.mvc-virtual-threads",
name = "enabled",
havingValue = "true")
public class VirtualThreadMvcConfiguration {
/** The validated profile, for the startup report and for anything that needs the budgets. */
@Bean
public VirtualThreadProfile virtualThreadProfile(VirtualThreadSettings properties) {
return properties.toProfile();
}
/**
* The concurrency limit the thread pool used to provide.
*
* <p>A fair semaphore over use cases, not a pool over threads. Bounding the threads would put the
* waiting back and throw away what virtual threads bought.
*/
@Bean
@ConditionalOnMissingBean
public VirtualThreadAdmissionGuard virtualThreadAdmissionGuard(VirtualThreadSettings properties) {
return new VirtualThreadAdmissionGuard(
properties.getAdmissionLimit(), properties.getAdmissionWait());
}
/**
* The executor Spring MVC dispatches async request handling onto.
*
* <p>Named {@code applicationTaskExecutor} because that is the bean Spring Boot's MVC async
* support looks for. A differently named bean is created, is never used, and leaves the container
* default in place — which is the failure mode where the whole capability is switched on and
* nothing changes.
*/
@Bean("applicationTaskExecutor")
@ConditionalOnMissingBean(name = "applicationTaskExecutor")
public AsyncTaskExecutor webMvcVirtualThreadExecutor(TaskDecorator contextDecorator) {
Executor delegate =
Executors.newThreadPerTaskExecutor(Thread.ofVirtual().name("web-mvc-vt-", 0).factory());
TaskExecutorAdapter executor = new TaskExecutorAdapter(delegate);
// Without it every async hop loses request_id, trace_id, correlation_id and tenant_id — and a
// virtual-thread executor makes that worse rather than better, because the carrier thread a
// task lands on has no relationship to the one that submitted it.
executor.setTaskDecorator(contextDecorator);
return executor;
}
}
@@ -0,0 +1,103 @@
package dev.caskeleton.adapter.inbound.web.advanced.mvc;
import dev.caskeleton.adapter.inbound.web.advanced.virtualthread.VirtualThreadProfile;
import jakarta.validation.constraints.AssertTrue;
import java.time.Duration;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;
/**
* Settings bound from {@code backend.web.advanced.mvc-virtual-threads.*}.
*
* <p>The downstream budgets are settings rather than something read from the pool at runtime,
* deliberately. Reading them would make the check pass automatically — whatever the pool is, the
* admission limit would be compared against it and the operator would never be asked to think about
* the relationship. Writing them down here is what makes a mismatch visible in a diff.
*/
@ConfigurationProperties(prefix = "backend.web.advanced.mvc-virtual-threads")
@Validated
public class VirtualThreadSettings {
/** Whether request handling runs on virtual threads. Activation must be explicit. */
private boolean enabled;
/** How many use cases may run at once. Not a thread count. */
private int admissionLimit;
/** The database connection pool, restated so a mismatch is visible. */
private int databasePoolSize;
/** The outbound HTTP bulkhead, restated for the same reason. */
private int outboundBulkhead;
/** How long an arrival waits for a permit before it is refused. */
private Duration admissionWait = Duration.ofMillis(250);
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public int getAdmissionLimit() {
return admissionLimit;
}
public void setAdmissionLimit(int admissionLimit) {
this.admissionLimit = admissionLimit;
}
public int getDatabasePoolSize() {
return databasePoolSize;
}
public void setDatabasePoolSize(int databasePoolSize) {
this.databasePoolSize = databasePoolSize;
}
public int getOutboundBulkhead() {
return outboundBulkhead;
}
public void setOutboundBulkhead(int outboundBulkhead) {
this.outboundBulkhead = outboundBulkhead;
}
public Duration getAdmissionWait() {
return admissionWait;
}
public void setAdmissionWait(Duration admissionWait) {
this.admissionWait = admissionWait;
}
/** The validated profile. */
public VirtualThreadProfile toProfile() {
return new VirtualThreadProfile(enabled, admissionLimit, databasePoolSize, outboundBulkhead);
}
@AssertTrue(
message =
"enabling virtual threads requires an admission limit and the downstream budgets it must"
+ " not exceed; the thread pool was the admission policy, and removing it without"
+ " replacing it accepts every arrival and queues them on budgets that did not grow")
public boolean isProfileValid() {
if (!enabled) {
return true;
}
try {
toProfile();
return true;
} catch (IllegalArgumentException refused) {
return false;
}
}
@AssertTrue(message = "the admission wait must be positive")
public boolean isAdmissionWaitValid() {
return !enabled
|| (admissionWait != null && !admissionWait.isNegative() && !admissionWait.isZero());
}
}
@@ -0,0 +1,72 @@
package dev.caskeleton.adapter.inbound.web.advanced.openapi;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
/**
* What differs between the Stable 3.1 document and the experimental 3.2 one, and whether 3.2 may be
* promoted.
*
* <p>The invariant that matters more than any difference: generating 3.2 must not change the 3.1
* snapshot. They are produced from the same model, so a contributor that mutates the shared model
* on the way to 3.2 changes the artifact that is actually shipped — and the 3.1 snapshot check
* would catch it only if somebody thought to run it in the same build.
*
* @param stableSnapshotHash the 3.1 snapshot's hash before generation
* @param stableSnapshotHashAfter its hash afterwards
* @param streamingDescriptionDifferences where the two documents describe streaming differently
* @param matrix which tools have been shown to read the 3.2 output
*/
public record OpenApi32CompatibilityReport(
String stableSnapshotHash,
String stableSnapshotHashAfter,
List<String> streamingDescriptionDifferences,
OpenApiToolchainMatrix matrix) {
public OpenApi32CompatibilityReport {
Objects.requireNonNull(stableSnapshotHash, "stableSnapshotHash");
Objects.requireNonNull(stableSnapshotHashAfter, "stableSnapshotHashAfter");
streamingDescriptionDifferences =
List.copyOf(
Objects.requireNonNull(
streamingDescriptionDifferences, "streamingDescriptionDifferences"));
Objects.requireNonNull(matrix, "matrix");
}
/** Whether generating 3.2 left the shipped artifact alone. */
public boolean stableArtifactUnchanged() {
return stableSnapshotHash.equals(stableSnapshotHashAfter);
}
/**
* Why 3.2 may not be promoted yet.
*
* <p>A list rather than a boolean, and it is never empty of its own accord: promotion also
* requires an ADR, which is not a machine-checkable condition and is therefore stated here as a
* blocker that a human removes.
*
* @param adrAccepted whether the promotion ADR has been accepted
*/
public List<String> promotionBlockers(boolean adrAccepted) {
List<String> blockers = new ArrayList<>();
if (!stableArtifactUnchanged()) {
blockers.add(
"generating 3.2 changed the 3.1 snapshot, so the two share mutable state and the shipped "
+ "artifact depends on whether the experimental lane ran");
}
if (!matrix.complete()) {
blockers.addAll(matrix.gaps());
}
if (!adrAccepted) {
blockers.add(
"no accepted promotion ADR; 3.2 stays experimental regardless of how green the matrix is");
}
return List.copyOf(blockers);
}
/** Whether 3.2 may become the release artifact. */
public boolean promotable(boolean adrAccepted) {
return promotionBlockers(adrAccepted).isEmpty();
}
}
@@ -0,0 +1,40 @@
package dev.caskeleton.adapter.inbound.web.advanced.openapi;
import io.swagger.v3.core.util.Json31;
import io.swagger.v3.oas.models.OpenAPI;
import java.util.Objects;
/**
* Copies an OpenAPI model by serialising and reading it back.
*
* <p>Through the document's own JSON rather than by walking the object graph, and the reason is
* that walking it is unmaintainable in the exact way that matters here. The model has dozens of
* mutable node types with nested maps and lists; a hand-written copy is correct only until swagger
* adds a field, and a missed field is a shared mutable node — which is the one failure this copy
* exists to prevent, appearing later and silently.
*
* <p>Serialising is slower and completely faithful. This runs once per build of the experimental
* document, so the cost is irrelevant and the faithfulness is not.
*/
final class OpenApiDeepCopy {
private OpenApiDeepCopy() {}
/**
* A copy that shares no mutable node with the original.
*
* @param source the document to copy
*/
static OpenAPI of(OpenAPI source) {
Objects.requireNonNull(source, "source");
try {
return Json31.mapper().readValue(Json31.mapper().writeValueAsString(source), OpenAPI.class);
} catch (Exception failure) {
// Not recoverable and not maskable. A copy that silently returned the original would let the
// experimental lane mutate the shipped artifact, which is precisely what this prevents.
throw new IllegalStateException(
"the Stable OpenAPI model could not be copied, so generating 3.2 would have to share it",
failure);
}
}
}
@@ -0,0 +1,81 @@
package dev.caskeleton.adapter.inbound.web.advanced.openapi;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
* Which tools have been shown to read a generated document.
*
* <p>Four distinct kinds of tool, because they fail differently and passing one says nothing about
* the others. A parser reads the document and reports errors. A linter applies style rules and will
* happily accept a document a parser rejects. A generator produces client code, and this is where
* an unsupported construct usually surfaces — not as an error but as a generated method with the
* wrong signature. A compile check on that generated code is the only step that catches it.
*
* <p>"OpenAPI 3.2 works" is therefore not a statement anybody can make. "This document is read
* correctly by these four tools at these versions" is.
*/
public final class OpenApiToolchainMatrix {
private final Map<ToolKind, Map<String, Boolean>> results = new LinkedHashMap<>();
/** Record a result. */
public OpenApiToolchainMatrix record(ToolKind kind, String toolVersion, boolean passed) {
Objects.requireNonNull(kind, "kind");
Objects.requireNonNull(toolVersion, "toolVersion");
results.computeIfAbsent(kind, key -> new LinkedHashMap<>()).put(toolVersion, passed);
return this;
}
/** Whether every kind has at least one passing tool. */
public boolean complete() {
for (ToolKind kind : ToolKind.values()) {
Map<String, Boolean> forKind = results.get(kind);
if (forKind == null || forKind.values().stream().noneMatch(Boolean::booleanValue)) {
return false;
}
}
return true;
}
/** What is missing, for the compatibility report. */
public List<String> gaps() {
return java.util.Arrays.stream(ToolKind.values())
.filter(
kind -> {
Map<String, Boolean> forKind = results.get(kind);
return forKind == null || forKind.values().stream().noneMatch(Boolean::booleanValue);
})
.map(kind -> kind.name() + ": no passing tool recorded")
.toList();
}
/** Everything recorded, for the report. */
public Map<ToolKind, Map<String, Boolean>> results() {
Map<ToolKind, Map<String, Boolean>> copy = new LinkedHashMap<>();
results.forEach((kind, byVersion) -> copy.put(kind, Map.copyOf(byVersion)));
return Map.copyOf(copy);
}
/** The kinds of tool that have to be checked separately. */
public enum ToolKind {
/** Reads the document and reports structural errors. */
PARSER,
/** Applies style rules; accepts documents a parser rejects. */
LINTER,
/** Produces client code. Where unsupported constructs usually surface. */
GENERATOR,
/**
* Compiles the generated client.
*
* <p>The only step that catches a generator emitting a wrong-but-syntactically-valid signature.
*/
CLIENT_COMPILE
}
}
@@ -0,0 +1,36 @@
package dev.caskeleton.adapter.inbound.web.advanced.openapi;
/**
* Which OpenAPI version a generated document targets.
*
* <p>Two lanes, and only one of them is the release artifact. The distinction exists because
* generating 3.2 is cheap and *adopting* it is not: the value of an API description is entirely in
* what consumes it, and a document in a version that a client generator does not understand is
* worse than no document — it produces a client that compiles and is wrong.
*/
public enum OpenApiVersionLane {
/** OpenAPI 3.1.2. The release artifact and the source of truth. */
STABLE_3_1("3.1.2", true),
/** OpenAPI 3.2.0. Generated in parallel, promoted only by an ADR. */
EXPERIMENTAL_3_2("3.2.0", false);
private final String version;
private final boolean releaseArtifact;
OpenApiVersionLane(String version, boolean releaseArtifact) {
this.version = version;
this.releaseArtifact = releaseArtifact;
}
/** The version string that goes in the document. */
public String version() {
return version;
}
/** Whether this lane's output is the thing shipped to consumers. */
public boolean releaseArtifact() {
return releaseArtifact;
}
}
@@ -0,0 +1,122 @@
package dev.caskeleton.adapter.inbound.web.advanced.openapi;
import dev.caskeleton.adapter.inbound.web.advanced.stream.encoding.StreamMediaType;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.Operation;
import io.swagger.v3.oas.models.PathItem;
import io.swagger.v3.oas.models.media.Content;
import io.swagger.v3.oas.models.media.MediaType;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Set;
/**
* Produces the experimental OpenAPI 3.2 document beside the Stable 3.1 one.
*
* <p>The generator works on a <strong>deep copy</strong> of the Stable model, and that is the whole
* reason this class exists rather than a two-line customizer. Both documents come from the same
* in-memory model, so a contributor that mutates it on the way to 3.2 changes the artifact that is
* actually shipped — silently, and only in builds where the experimental lane happened to run.
* Copying is the only thing that makes the two independent; the snapshot-hash check in {@link
* OpenApi32CompatibilityReport} is what proves the copy held.
*
* <p>What 3.2 adds for this application is streaming description. 3.1 can say a response is {@code
* text/event-stream} and cannot say what one event looks like, so a generator produces a client
* that treats the whole stream as one body. 3.2's {@code itemSchema} says what each item is. That
* difference is reported rather than applied silently, because it changes what a generated client
* does.
*/
public final class WebOpenApi32Generator {
/** The media types whose per-item schema 3.2 can express and 3.1 cannot. */
private static final Set<String> STREAMING_MEDIA_TYPES =
Set.of(StreamMediaType.SSE, StreamMediaType.NDJSON, StreamMediaType.JSON_SEQ);
private final List<String> streamingDifferences = new ArrayList<>();
/**
* Generate the 3.2 document.
*
* @param stable the Stable model, which is not modified
* @return a separate document at version 3.2.0
*/
public OpenAPI generate(OpenAPI stable) {
Objects.requireNonNull(stable, "stable");
streamingDifferences.clear();
// A copy, not the model. See the class comment: sharing it makes the shipped artifact depend on
// whether this lane ran.
OpenAPI experimental = OpenApiDeepCopy.of(stable);
experimental.setOpenapi(OpenApiVersionLane.EXPERIMENTAL_3_2.version());
describeStreaming(experimental);
return experimental;
}
/**
* Where the two documents describe streaming differently.
*
* <p>Valid after {@link #generate}. Reported separately rather than folded into a pass/fail,
* because a green pass hides what changed and the change is the point.
*/
public List<String> streamingDifferences() {
return List.copyOf(streamingDifferences);
}
private void describeStreaming(OpenAPI document) {
if (document.getPaths() == null) {
return;
}
document
.getPaths()
.forEach(
(path, item) ->
operationsOf(item)
.forEach(operation -> describeStreamingResponses(path, operation)));
}
private void describeStreamingResponses(String path, Operation operation) {
if (operation.getResponses() == null) {
return;
}
operation
.getResponses()
.forEach(
(status, response) -> {
Content content = response.getContent();
if (content == null) {
return;
}
content.forEach(
(mediaType, media) -> {
if (STREAMING_MEDIA_TYPES.contains(mediaType)) {
applyItemSchema(path, status, mediaType, media);
}
});
});
}
private void applyItemSchema(String path, String status, String mediaType, MediaType media) {
if (media.getSchema() == null) {
// Nothing to lift. A streaming response with no schema is under-described in both versions,
// and inventing one here would make 3.2 look better than the source model actually is.
return;
}
// 3.2 carries the per-item schema in `itemSchema`; the swagger-models version on this
// classpath is a 3.1 model, so the field is added as an extension rather than as a typed
// property. That is honest about what this build can express and keeps the document valid.
media.addExtension("x-itemSchema", media.getSchema());
streamingDifferences.add(
path
+ " "
+ status
+ " "
+ mediaType
+ ": 3.2 describes the per-item schema, 3.1 describes only the stream body — a"
+ " generator reading the 3.1 document produces a client that treats the whole stream"
+ " as one response");
}
private static List<Operation> operationsOf(PathItem item) {
return item.readOperations();
}
}
@@ -0,0 +1,87 @@
package dev.caskeleton.adapter.inbound.web.advanced.patch;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.node.ObjectNode;
/**
* Applies an RFC 7396 merge patch to a typed target.
*
* <p>Via a tree rather than by mutating the object, because a merge patch's defining behaviour —
* {@code null} means delete, absent means leave alone — has no representation in a Java object. A
* DTO deserialized from a partial patch cannot distinguish a field the caller set to null from one
* they did not mention: both are a null field. That single ambiguity is the whole reason merge
* patch implementations get written against a tree.
*
* <p>The result is re-validated as a whole document. A patch that is individually valid can produce
* an object that is not — two fields that must agree, a state that only some transitions allow —
* and validating only the changed fields sees none of it.
*/
public final class JsonMergePatchApplier {
private final ObjectMapper mapper;
private final PatchFieldAuthorization authorization;
/**
* @param mapper the strict mapper the rest of the leaf uses
* @param authorization which fields may be modified
*/
public JsonMergePatchApplier(ObjectMapper mapper, PatchFieldAuthorization authorization) {
this.mapper = Objects.requireNonNull(mapper, "mapper");
this.authorization = Objects.requireNonNull(authorization, "authorization");
}
/**
* Apply a patch.
*
* @param current the value as it is now
* @param patch the patch
* @param type the target type
* @param <T> the target type
*/
public <T> PatchResult<T> apply(T current, JsonMergePatchDocument patch, Class<T> type) {
Objects.requireNonNull(current, "current");
Objects.requireNonNull(patch, "patch");
Objects.requireNonNull(type, "type");
// Authorization first, before anything is merged. Checking afterwards would mean deciding
// whether a refused field "actually changed anything", and a caller who can ask that question
// can use it to probe values they may not read.
authorization.validate(patch.fieldNames());
JsonNode before = mapper.valueToTree(current);
if (!before.isObject()) {
throw new IllegalArgumentException("a merge patch target must serialize to a JSON object");
}
ObjectNode after = merge((ObjectNode) before.deepCopy(), patch.root());
List<String> modified = new ArrayList<>();
for (String field : patch.fieldNames()) {
JsonNode was = before.get(field);
JsonNode now = after.get(field);
if (!Objects.equals(was, now)) {
modified.add(field);
}
}
return new PatchResult<>(mapper.treeToValue(after, type), modified);
}
private static ObjectNode merge(ObjectNode target, ObjectNode patch) {
for (String field : patch.propertyNames()) {
JsonNode value = patch.get(field);
if (value.isNull()) {
// RFC 7396: null removes the member. Not "sets it to null" — the distinction matters for a
// target whose field is itself nullable.
target.remove(field);
} else if (value.isObject() && target.get(field) != null && target.get(field).isObject()) {
merge((ObjectNode) target.get(field), (ObjectNode) value);
} else {
// Arrays are replaced whole, never merged element-wise. RFC 7396 is explicit, and the
// alternative has no sensible definition: there is no way to say "change element 3" in a
// merge patch.
target.set(field, value);
}
}
return target;
}
}
@@ -0,0 +1,81 @@
package dev.caskeleton.adapter.inbound.web.advanced.patch;
import java.util.List;
import java.util.Objects;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.node.ObjectNode;
/**
* A parsed RFC 7396 merge patch.
*
* <p>Parsed once and carried as a value, so the depth and size checks happen at the boundary rather
* than inside the merge loop. A merge patch nests arbitrarily and the merge is recursive; without a
* depth bound, a body of ten thousand nested objects is a stack overflow, and a stack overflow
* inside a request thread is not something the error handler can turn into a 400.
*
* @param root the patch document
*/
public record JsonMergePatchDocument(ObjectNode root) {
/** Deep enough for any real document, shallow enough that recursion cannot exhaust the stack. */
public static final int MAX_DEPTH = 32;
public JsonMergePatchDocument {
Objects.requireNonNull(root, "root");
int depth = depthOf(root, 1);
if (depth > MAX_DEPTH) {
throw new IllegalArgumentException(
"a merge patch nested "
+ depth
+ " deep exceeds the "
+ MAX_DEPTH
+ " limit; the merge is recursive, and a stack overflow inside a request thread is "
+ "not something an exception handler can turn into a 400");
}
}
/**
* Parse a body.
*
* @param mapper the strict mapper
* @param body the request body
* @throws IllegalArgumentException if the body is not a JSON object
*/
public static JsonMergePatchDocument parse(ObjectMapper mapper, byte[] body) {
Objects.requireNonNull(mapper, "mapper");
Objects.requireNonNull(body, "body");
JsonNode parsed = mapper.readTree(body);
if (!parsed.isObject()) {
// RFC 7396 permits a scalar or an array as a whole-document replacement. Refused here: a
// merge patch that replaces the entire resource is a PUT, and routing it through the patch
// path skips the full-document validation a PUT gets.
throw new IllegalArgumentException(
"a merge patch must be a JSON object; a scalar or array patch replaces the whole "
+ "resource, which is a PUT and belongs on the PUT route with its validation");
}
return new JsonMergePatchDocument((ObjectNode) parsed);
}
/** The top-level field names the patch touches. */
public List<String> fieldNames() {
return root.propertyNames().stream().sorted().toList();
}
/** Whether the patch deletes a field by setting it to null. */
public boolean deletes(String field) {
JsonNode value = root.get(field);
return value != null && value.isNull();
}
private static int depthOf(JsonNode node, int current) {
if (!node.isObject()) {
return current;
}
int deepest = current;
for (JsonNode child : node.values()) {
deepest = Math.max(deepest, depthOf(child, current + 1));
}
return deepest;
}
}
@@ -0,0 +1,190 @@
package dev.caskeleton.adapter.inbound.web.advanced.patch;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.node.ArrayNode;
import tools.jackson.databind.node.ObjectNode;
/**
* Applies an RFC 6902 patch atomically.
*
* <p>Atomic in the only way that matters here: every operation runs against a working copy, and the
* caller's object is never touched until all of them have succeeded and the result has passed
* validation. A patch that fails at operation seven leaves the caller with exactly what they had.
*
* <p>That is not an optimisation. RFC 6902 requires it — "if a normative requirement is violated,
* the entire patch document MUST NOT be applied" — and it is the property {@code test} exists to
* exploit. A client putting a {@code test} first is relying on nothing after it having happened
* when the test fails.
*
* <p>Authorization runs before the first operation too, and for the same reason a merge patch's
* does: deciding afterwards whether a refused operation "actually changed anything" is a question
* whose answer leaks the value.
*/
public final class JsonPatchApplier {
private final ObjectMapper mapper;
private final JsonPointerAuthorization authorization;
/**
* @param mapper the strict mapper the rest of the leaf uses
* @param authorization which pointers may be modified
*/
public JsonPatchApplier(ObjectMapper mapper, JsonPointerAuthorization authorization) {
this.mapper = Objects.requireNonNull(mapper, "mapper");
this.authorization = Objects.requireNonNull(authorization, "authorization");
}
/**
* Apply a document.
*
* @param current the value as it is now, which is not modified
* @param patch the operations
* @param type the target type
* @param <T> the target type
*/
public <T> PatchResult<T> apply(T current, JsonPatchDocument patch, Class<T> type) {
Objects.requireNonNull(current, "current");
Objects.requireNonNull(patch, "patch");
Objects.requireNonNull(type, "type");
authorization.validate(patch.operations());
JsonNode before = mapper.valueToTree(current);
JsonNode working = before.deepCopy();
List<String> touched = new ArrayList<>();
for (JsonPatchOperation operation : patch.operations()) {
working = applyOne(working, operation);
if (operation.kind().mutating()) {
touched.add(operation.path());
}
}
if (working.equals(before)) {
return new PatchResult<>(mapper.treeToValue(working, type), List.of());
}
return new PatchResult<>(mapper.treeToValue(working, type), touched);
}
private JsonNode applyOne(JsonNode root, JsonPatchOperation operation) {
return switch (operation.kind()) {
case TEST -> {
JsonNode found = at(root, operation.path());
if (found == null || !found.equals(operation.value().orElseThrow())) {
throw new JsonPatchTestFailedException(operation.path());
}
yield root;
}
case ADD, REPLACE -> {
if (operation.kind() == JsonPatchOperationKind.REPLACE
&& at(root, operation.path()) == null) {
// RFC 6902: replace requires the target to exist. Treating it as an add turns a client's
// "change this" into "create this", which is how a field the resource never had appears.
throw new JsonPatchRejectedException(
"replace requires the target location to exist: " + operation.path());
}
yield set(root, operation.path(), operation.value().orElseThrow());
}
case REMOVE -> {
if (at(root, operation.path()) == null) {
throw new JsonPatchRejectedException(
"remove requires the target location to exist: " + operation.path());
}
yield remove(root, operation.path());
}
case COPY -> {
JsonNode source = required(root, operation.from().orElseThrow());
yield set(root, operation.path(), source.deepCopy());
}
case MOVE -> {
String from = operation.from().orElseThrow();
JsonNode source = required(root, from).deepCopy();
yield set(remove(root, from), operation.path(), source);
}
};
}
private JsonNode required(JsonNode root, String pointer) {
JsonNode found = at(root, pointer);
if (found == null) {
throw new JsonPatchRejectedException("source location does not exist: " + pointer);
}
return found;
}
private static JsonNode at(JsonNode root, String pointer) {
JsonNode found = root.at(pointer);
return found.isMissingNode() ? null : found;
}
private JsonNode set(JsonNode root, String pointer, JsonNode value) {
if (pointer.isEmpty()) {
return value;
}
int lastSlash = pointer.lastIndexOf('/');
String parentPointer = pointer.substring(0, lastSlash);
String key = unescape(pointer.substring(lastSlash + 1));
JsonNode parent = parentPointer.isEmpty() ? root : at(root, parentPointer);
if (parent == null) {
throw new JsonPatchRejectedException("parent location does not exist: " + parentPointer);
}
if (parent.isObject()) {
((ObjectNode) parent).set(key, value);
return root;
}
if (parent.isArray()) {
ArrayNode array = (ArrayNode) parent;
if (key.equals("-")) {
array.add(value);
return root;
}
int index = index(key, array.size(), true);
array.insert(index, value);
return root;
}
throw new JsonPatchRejectedException("cannot address into a scalar: " + parentPointer);
}
private JsonNode remove(JsonNode root, String pointer) {
int lastSlash = pointer.lastIndexOf('/');
String parentPointer = pointer.substring(0, lastSlash);
String key = unescape(pointer.substring(lastSlash + 1));
JsonNode parent = parentPointer.isEmpty() ? root : at(root, parentPointer);
if (parent == null) {
throw new JsonPatchRejectedException("parent location does not exist: " + parentPointer);
}
if (parent.isObject()) {
((ObjectNode) parent).remove(key);
return root;
}
if (parent.isArray()) {
ArrayNode array = (ArrayNode) parent;
array.remove(index(key, array.size(), false));
return root;
}
throw new JsonPatchRejectedException("cannot address into a scalar: " + parentPointer);
}
private static int index(String key, int size, boolean insert) {
int parsed;
try {
parsed = Integer.parseInt(key);
} catch (NumberFormatException notANumber) {
throw new JsonPatchRejectedException("array index expected, got: " + key);
}
// Bounds checked here rather than relying on the node's own behaviour: Jackson's insert()
// clamps out-of-range indices, so an index of 900 into a three-element array would silently
// append instead of failing.
int ceiling = insert ? size : size - 1;
if (parsed < 0 || parsed > ceiling) {
throw new JsonPatchRejectedException("array index out of range: " + key);
}
return parsed;
}
private static String unescape(String token) {
// RFC 6901 section 3, in this order: ~1 first, then ~0. The reverse turns "~01" into "/"
// instead of "~1".
return token.replace("~1", "/").replace("~0", "~");
}
}
@@ -0,0 +1,90 @@
package dev.caskeleton.adapter.inbound.web.advanced.patch;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
/**
* A parsed RFC 6902 patch document.
*
* <p>Bounded in count as well as in pointer depth. The two multiply: each operation walks its
* pointer, so a thousand operations at a hundred segments each is a hundred thousand traversals of
* a document the server also has to deep-copy first. An operation limit alone does not bound the
* work and a depth limit alone does not either.
*
* @param operations the operations, in order
*/
public record JsonPatchDocument(List<JsonPatchOperation> operations) {
/** More than any real patch and few enough that the total traversal cost is bounded. */
public static final int MAX_OPERATIONS = 100;
public JsonPatchDocument {
operations = List.copyOf(Objects.requireNonNull(operations, "operations"));
if (operations.isEmpty()) {
throw new IllegalArgumentException(
"an empty patch document changes nothing; sending one is a client bug and answering 200 "
+ "hides it");
}
if (operations.size() > MAX_OPERATIONS) {
throw new JsonPatchRejectedException(
"a patch of "
+ operations.size()
+ " operations exceeds the "
+ MAX_OPERATIONS
+ " limit; each one walks its pointer over a document the server deep-copied first");
}
}
/**
* Parse a body.
*
* @param mapper the strict mapper
* @param body the request body
*/
public static JsonPatchDocument parse(ObjectMapper mapper, byte[] body) {
Objects.requireNonNull(mapper, "mapper");
Objects.requireNonNull(body, "body");
JsonNode root = mapper.readTree(body);
if (!root.isArray()) {
throw new JsonPatchRejectedException(
"an RFC 6902 patch is an array of operations; an object here is a merge patch sent to "
+ "the wrong route, and merging it would report success having changed nothing");
}
List<JsonPatchOperation> parsed =
root.values().stream().map(JsonPatchDocument::parseOperation).toList();
return new JsonPatchDocument(parsed);
}
/** Whether any operation would modify the target. */
public boolean mutating() {
return operations.stream().anyMatch(operation -> operation.kind().mutating());
}
private static JsonPatchOperation parseOperation(JsonNode node) {
if (!node.isObject()) {
throw new JsonPatchRejectedException("each patch operation is a JSON object");
}
JsonNode op = node.get("op");
JsonPatchOperationKind kind =
JsonPatchOperationKind.fromWire(op == null ? null : op.asString())
.orElseThrow(
() ->
new JsonPatchRejectedException(
"unrecognised patch operation; RFC 6902 requires this to be an error "
+ "rather than a skip, because skipping one turns a document the "
+ "client believed was atomic into a partial application"));
JsonNode path = node.get("path");
if (path == null || !path.isString()) {
throw new JsonPatchRejectedException("each patch operation names a path");
}
JsonNode from = node.get("from");
return new JsonPatchOperation(
kind,
path.asString(),
Optional.ofNullable(node.get("value")),
Optional.ofNullable(from).filter(JsonNode::isString).map(JsonNode::asString));
}
}
@@ -0,0 +1,71 @@
package dev.caskeleton.adapter.inbound.web.advanced.patch;
import java.util.Objects;
import java.util.Optional;
import tools.jackson.databind.JsonNode;
/**
* One RFC 6902 operation, with its members checked against what its kind requires.
*
* <p>Checked at construction rather than at application, because the document is atomic: an
* operation missing its {@code value} discovered halfway through is a document that has already
* modified the working copy. Everything that can be known before the first change is established
* before the first change.
*
* @param kind which operation
* @param path the pointer it acts on
* @param value the operand, present for add, replace and test
* @param from the source pointer, present for move and copy
*/
public record JsonPatchOperation(
JsonPatchOperationKind kind, String path, Optional<JsonNode> value, Optional<String> from) {
public JsonPatchOperation {
Objects.requireNonNull(kind, "kind");
Objects.requireNonNull(path, "path");
Objects.requireNonNull(value, "value");
Objects.requireNonNull(from, "from");
if (!path.isEmpty() && !path.startsWith("/")) {
throw new IllegalArgumentException("a JSON Pointer is empty or starts with '/': " + path);
}
if (kind.requiresValue() != value.isPresent()) {
throw new IllegalArgumentException(
kind.wireName()
+ (kind.requiresValue()
? " requires a value member"
: " carries no value member, and one here means the client meant a different op"));
}
if (kind.requiresFrom() != from.isPresent()) {
throw new IllegalArgumentException(
kind.wireName()
+ (kind.requiresFrom() ? " requires a from member" : " carries no from member"));
}
if (from.isPresent() && !from.get().isEmpty() && !from.get().startsWith("/")) {
throw new IllegalArgumentException("a JSON Pointer is empty or starts with '/'");
}
if (kind == JsonPatchOperationKind.MOVE
&& from.isPresent()
&& path.startsWith(from.get() + "/")) {
// RFC 6902 section 4.4: the target cannot be inside the source. Moving a subtree into itself
// is not a thing that has a result, and an implementation that tries produces either an
// infinite structure or a silently truncated one.
throw new IllegalArgumentException("a move cannot target a location inside its own source");
}
}
/** An operation with a value. */
public static JsonPatchOperation of(JsonPatchOperationKind kind, String path, JsonNode value) {
return new JsonPatchOperation(kind, path, Optional.of(value), Optional.empty());
}
/** A remove. */
public static JsonPatchOperation remove(String path) {
return new JsonPatchOperation(
JsonPatchOperationKind.REMOVE, path, Optional.empty(), Optional.empty());
}
/** A move or a copy. */
public static JsonPatchOperation relocate(JsonPatchOperationKind kind, String from, String path) {
return new JsonPatchOperation(kind, path, Optional.empty(), Optional.of(from));
}
}
@@ -0,0 +1,83 @@
package dev.caskeleton.adapter.inbound.web.advanced.patch;
import java.util.Locale;
import java.util.Optional;
/**
* The six RFC 6902 operations.
*
* <p>{@link #TEST} is the one that makes JSON Patch more than a list of edits: it is an assertion
* evaluated in sequence, and a failed one aborts the whole document. That is how a client expresses
* an optimistic-concurrency check that {@code If-Match} cannot — a precondition on one field rather
* than on the whole resource.
*/
public enum JsonPatchOperationKind {
/** Insert a value. */
ADD("add", true, false),
/** Delete a value. */
REMOVE("remove", false, false),
/** Overwrite a value. */
REPLACE("replace", true, false),
/** Relocate a value. */
MOVE("move", false, true),
/** Duplicate a value. */
COPY("copy", false, true),
/** Assert a value, aborting the document if it does not hold. */
TEST("test", true, false);
private final String wireName;
private final boolean requiresValue;
private final boolean requiresFrom;
JsonPatchOperationKind(String wireName, boolean requiresValue, boolean requiresFrom) {
this.wireName = wireName;
this.requiresValue = requiresValue;
this.requiresFrom = requiresFrom;
}
/** The name as it appears in the {@code op} member. */
public String wireName() {
return wireName;
}
/** Whether the operation must carry a {@code value}. */
public boolean requiresValue() {
return requiresValue;
}
/** Whether it must carry a {@code from} pointer. */
public boolean requiresFrom() {
return requiresFrom;
}
/** Whether it can modify the target. {@code test} cannot. */
public boolean mutating() {
return this != TEST;
}
/**
* Parse an {@code op} member.
*
* <p>Empty for anything unrecognised, and the caller refuses. RFC 6902 requires an unrecognised
* operation to be an error rather than a skip: skipping one turns a document the client believed
* was applied atomically into a partial application.
*/
public static Optional<JsonPatchOperationKind> fromWire(String op) {
if (op == null) {
return Optional.empty();
}
String normalized = op.trim().toLowerCase(Locale.ROOT);
for (JsonPatchOperationKind kind : values()) {
if (kind.wireName.equals(normalized)) {
return Optional.of(kind);
}
}
return Optional.empty();
}
}
@@ -0,0 +1,11 @@
package dev.caskeleton.adapter.inbound.web.advanced.patch;
/** A JSON Patch document that will not be applied, and was not partially applied either. */
public final class JsonPatchRejectedException extends RuntimeException {
private static final long serialVersionUID = 1L;
public JsonPatchRejectedException(String message) {
super(message);
}
}
@@ -0,0 +1,31 @@
package dev.caskeleton.adapter.inbound.web.advanced.patch;
import java.util.Objects;
/**
* A {@code test} operation did not hold, so nothing was applied.
*
* <p>Distinct from a rejection, because it means something different to the caller: the document
* was well-formed and permitted, and the resource simply was not in the state the client expected.
* That is a 409, not a 400, and a client that retries after re-reading will usually succeed.
*
* <p>The pointer is echoed because it came from the client's own patch. The value is not: it is the
* server's, and returning it would turn {@code test} into a read primitive for fields the caller
* may not read.
*/
public final class JsonPatchTestFailedException extends RuntimeException {
private static final long serialVersionUID = 1L;
private final transient String pointer;
public JsonPatchTestFailedException(String pointer) {
super("a test operation did not hold, so no operation was applied");
this.pointer = Objects.requireNonNull(pointer, "pointer");
}
/** Which pointer failed. */
public String pointer() {
return pointer;
}
}
@@ -0,0 +1,95 @@
package dev.caskeleton.adapter.inbound.web.advanced.patch;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Set;
/**
* Which JSON Pointers a patch may address, and how deep.
*
* <p>Harder than the merge-patch equivalent, and worth stating why. A merge patch names top-level
* fields; a JSON Pointer addresses arbitrary depth, so {@code /profile/displayName} and {@code
* /profile} are different permissions over the same data, and an allowlist of prefixes has to
* decide whether permission on the parent implies permission on the child.
*
* <p>It does — a caller who may replace {@code /profile} wholesale can already set any field inside
* it — but the reverse must not: permission on {@code /profile/displayName} cannot grant {@code
* /profile}, because replacing the parent deletes every sibling.
*
* <p>Depth is bounded separately. A pointer's depth is the client's choice and each segment is a
* tree traversal, so an unbounded pointer is unbounded work per operation, multiplied by the
* operation limit.
*/
public final class JsonPointerAuthorization {
/** Deeper than any real document, and bounded so the traversal cost is. */
public static final int MAX_POINTER_DEPTH = 16;
private final Set<String> writablePrefixes;
private JsonPointerAuthorization(Set<String> writablePrefixes) {
this.writablePrefixes = Set.copyOf(writablePrefixes);
}
/** Only pointers at or under these prefixes may be modified. */
public static JsonPointerAuthorization allow(String... prefixes) {
Objects.requireNonNull(prefixes, "prefixes");
if (prefixes.length == 0) {
throw new IllegalArgumentException(
"an empty allowlist refuses every patch; if the resource is not patchable, do not expose "
+ "the route");
}
for (String prefix : prefixes) {
if (prefix == null || !prefix.startsWith("/") || prefix.endsWith("/")) {
throw new IllegalArgumentException(
"a JSON Pointer prefix must start with '/' and not end with one: " + prefix);
}
}
return new JsonPointerAuthorization(Set.of(prefixes));
}
/** Whether a pointer may be modified. */
public boolean writable(String pointer) {
if (pointer == null) {
return false;
}
if (depthOf(pointer) > MAX_POINTER_DEPTH) {
return false;
}
// At-or-under, never above. Permission on a child cannot grant the parent, because replacing
// the parent deletes every sibling the caller has no permission for.
return writablePrefixes.stream()
.anyMatch(prefix -> pointer.equals(prefix) || pointer.startsWith(prefix + "/"));
}
/**
* Check every pointer a document names, including {@code from} pointers.
*
* @throws PatchAuthorizationException naming all of them
*/
public void validate(List<JsonPatchOperation> operations) {
Objects.requireNonNull(operations, "operations");
List<String> refused = new ArrayList<>();
for (JsonPatchOperation operation : operations) {
if (operation.kind().mutating() && !writable(operation.path())) {
refused.add(operation.path());
}
// A move reads from its source and deletes it, so the source needs write permission too —
// checking only the destination lets a caller relocate data out of a field they may not
// touch.
operation.from().filter(from -> !writable(from)).ifPresent(refused::add);
}
if (!refused.isEmpty()) {
refused.sort(String::compareTo);
throw new PatchAuthorizationException(refused);
}
}
private static int depthOf(String pointer) {
if (pointer.isEmpty() || pointer.equals("/")) {
return 0;
}
return (int) pointer.chars().filter(c -> c == '/').count();
}
}
@@ -0,0 +1,27 @@
package dev.caskeleton.adapter.inbound.web.advanced.patch;
import java.util.List;
import java.util.Objects;
/**
* A patch tried to touch something the caller may not.
*
* <p>Names the fields, because the caller needs to know which of them was refused and the names are
* from the server's allowlist rather than from the request — so they are safe to echo.
*/
public final class PatchAuthorizationException extends RuntimeException {
private static final long serialVersionUID = 1L;
private final transient List<String> refusedPaths;
public PatchAuthorizationException(List<String> refusedPaths) {
super("the patch touches fields this caller may not modify: " + refusedPaths);
this.refusedPaths = List.copyOf(Objects.requireNonNull(refusedPaths, "refusedPaths"));
}
/** Which paths were refused. */
public List<String> refusedPaths() {
return refusedPaths;
}
}
@@ -0,0 +1,69 @@
package dev.caskeleton.adapter.inbound.web.advanced.patch;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Set;
/**
* Which top-level fields a merge patch may modify.
*
* <p>An allowlist, and the direction matters more here than almost anywhere else in the leaf. A
* merge patch is a partial document: the caller sends only what changes, so there is no DTO whose
* absent fields say "not permitted". Without an allowlist, the set of modifiable fields is whatever
* the target type happens to have — which grows every time somebody adds a field to it, silently,
* and includes the ones that should never have been client-writable.
*
* <p>A denylist would be the wrong shape for exactly that reason: it would have to be updated every
* time the type gains a field, and forgetting is the failure.
*/
public final class PatchFieldAuthorization {
private final Set<String> writableFields;
private PatchFieldAuthorization(Set<String> writableFields) {
this.writableFields = Set.copyOf(writableFields);
}
/** Only these fields may be modified. */
public static PatchFieldAuthorization allow(String... fields) {
Objects.requireNonNull(fields, "fields");
if (fields.length == 0) {
throw new IllegalArgumentException(
"an empty allowlist refuses every patch; if the resource is not patchable, do not expose "
+ "the route");
}
return new PatchFieldAuthorization(Set.of(fields));
}
/** Whether a field may be modified. */
public boolean writable(String field) {
return writableFields.contains(field);
}
/** The allowlist, for the startup report. */
public List<String> writableFields() {
return writableFields.stream().sorted().toList();
}
/**
* Check every field a patch names.
*
* @throws PatchAuthorizationException naming all of them, not just the first
*/
public void validate(Iterable<String> fields) {
Objects.requireNonNull(fields, "fields");
List<String> refused = new ArrayList<>();
for (String field : fields) {
if (!writable(field)) {
refused.add(field);
}
}
if (!refused.isEmpty()) {
// All of them, so a caller fixing a patch does not discover the refusals one round trip at a
// time.
refused.sort(String::compareTo);
throw new PatchAuthorizationException(refused);
}
}
}
@@ -0,0 +1,23 @@
package dev.caskeleton.adapter.inbound.web.advanced.patch;
/**
* The two patch media types, and the reason a route accepts exactly one.
*
* <p>They are not interchangeable and the difference is silent. {@code {"a": null}} as a merge
* patch deletes {@code a}; as a JSON patch it is not a patch at all — it is an object where an
* array was required. Worse in the other direction: a JSON patch array read as a merge patch is a
* document whose fields are array indices, which merges nothing and reports success.
*
* <p>So a route declares one, and a body sent with the other content type is a 415 rather than a
* best-effort guess.
*/
public final class PatchMediaType {
/** RFC 7396. */
public static final String MERGE_PATCH = "application/merge-patch+json";
/** RFC 6902. */
public static final String JSON_PATCH = "application/json-patch+json";
private PatchMediaType() {}
}
@@ -0,0 +1,28 @@
package dev.caskeleton.adapter.inbound.web.advanced.patch;
import java.util.List;
import java.util.Objects;
/**
* A patched value and what the patch actually did.
*
* <p>The changed-field list is not decoration. A merge patch that sets a field to the value it
* already had is indistinguishable from one that changed it, unless somebody compared — and the
* difference decides whether an audit entry is written and whether a version is bumped.
*
* @param value the patched object
* @param modifiedFields which fields the patch actually changed
* @param <T> the target type
*/
public record PatchResult<T>(T value, List<String> modifiedFields) {
public PatchResult {
Objects.requireNonNull(value, "value");
modifiedFields = List.copyOf(Objects.requireNonNull(modifiedFields, "modifiedFields"));
}
/** Whether the patch changed anything at all. */
public boolean changed() {
return !modifiedFields.isEmpty();
}
}
@@ -0,0 +1,73 @@
package dev.caskeleton.adapter.inbound.web.advanced.ratelimit;
import dev.caskeleton.adapter.inbound.web.ratelimit.RateLimitDecision;
import java.time.Duration;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
/**
* Writes the draft {@code RateLimit} headers, when they are turned on.
*
* <p>Additive only. This writer never touches the status, never touches {@code Retry-After}, and
* returns an empty map when disabled — so a deployment that turns it off behaves exactly as a
* Stable one, which is the property the rollback test asserts.
*
* <p>The reset value is a delta in seconds rather than a timestamp. Both drafts specify a delta,
* and the reason is clock skew: a client whose clock is two minutes fast reads an absolute reset
* time as already past and retries immediately, which is the behaviour the header exists to
* prevent.
*/
public final class RateLimitDraftHeaderWriter {
private final RateLimitDraftProfile profile;
public RateLimitDraftHeaderWriter(RateLimitDraftProfile profile) {
this.profile = Objects.requireNonNull(profile, "profile");
}
/**
* The headers for a decision.
*
* @param decision the Stable rate-limit decision
* @param now the current instant, for the reset delta
*/
public Map<String, String> write(RateLimitDecision decision, Instant now) {
Objects.requireNonNull(decision, "decision");
Objects.requireNonNull(now, "now");
if (!profile.enabled()) {
return Map.of();
}
long resetSeconds = secondsUntil(decision.resetAt(), now);
Map<String, String> headers = new LinkedHashMap<>();
if (profile.version().structured()) {
headers.put(
"RateLimit",
"limit="
+ decision.limit()
+ ", remaining="
+ decision.remaining()
+ ", reset="
+ resetSeconds);
headers.put("RateLimit-Policy", "q=" + decision.limit() + ";w=" + profile.windowSeconds());
} else {
headers.put("RateLimit-Limit", Long.toString(decision.limit()));
headers.put("RateLimit-Remaining", Long.toString(decision.remaining()));
headers.put("RateLimit-Reset", Long.toString(resetSeconds));
}
return Map.copyOf(headers);
}
/** Which draft is being emitted, for the response artifact and the compatibility report. */
public String draftLabel() {
return profile.version().label();
}
private static long secondsUntil(Instant resetAt, Instant now) {
long seconds = Duration.between(now, resetAt).toSeconds();
// Never negative. A reset already in the past reads as "retry now" to a client that clamps and
// as an enormous unsigned number to one that does not.
return Math.max(seconds, 0L);
}
}
@@ -0,0 +1,36 @@
package dev.caskeleton.adapter.inbound.web.advanced.ratelimit;
import java.util.Objects;
/**
* Whether draft rate-limit headers are emitted, and under which draft.
*
* <p>Off by default, and additive when on. The Stable contract is the 429 status and the {@code
* Retry-After} header; both are standardised, both are what a correct client acts on, and neither
* changes when this is enabled. These headers are extra information for clients that know to look.
*
* @param enabled whether the headers are written
* @param version which draft
* @param windowSeconds the quota window, needed for the policy field
*/
public record RateLimitDraftProfile(
boolean enabled, RateLimitDraftVersion version, long windowSeconds) {
public RateLimitDraftProfile {
Objects.requireNonNull(version, "version");
if (enabled && windowSeconds < 1) {
throw new IllegalArgumentException(
"the policy field states the window, and a window of zero describes no policy");
}
}
/** Off. */
public static RateLimitDraftProfile disabled() {
return new RateLimitDraftProfile(false, RateLimitDraftVersion.DRAFT_11, 0);
}
/** On, under a named draft. */
public static RateLimitDraftProfile enabled(RateLimitDraftVersion version, long windowSeconds) {
return new RateLimitDraftProfile(true, version, windowSeconds);
}
}
@@ -0,0 +1,41 @@
package dev.caskeleton.adapter.inbound.web.advanced.ratelimit;
/**
* Which draft of the IETF RateLimit header field specification is being emitted.
*
* <p>Named in the response and in the test artifacts, because the drafts are not compatible with
* each other and a client written against one silently misreads another. Draft 07 used separate
* {@code RateLimit-Limit}, {@code RateLimit-Remaining} and {@code RateLimit-Reset} fields; draft 11
* folds them into one structured {@code RateLimit} field with a companion {@code RateLimit-Policy}.
* A client reading {@code RateLimit-Remaining} against a draft-11 server finds nothing and
* concludes it has no quota information — or, worse, treats the absent field as zero.
*
* <p>This is why the whole thing is Advanced and experimental: the header is not standardised, so
* emitting it is a promise the platform cannot keep across a version bump.
*/
public enum RateLimitDraftVersion {
/** draft-ietf-httpapi-ratelimit-headers-07: three separate fields. */
DRAFT_07("draft-07", false),
/** draft-ietf-httpapi-ratelimit-headers-11: one structured field plus a policy field. */
DRAFT_11("draft-11", true);
private final String label;
private final boolean structured;
RateLimitDraftVersion(String label, boolean structured) {
this.label = label;
this.structured = structured;
}
/** The version, as it appears in a report. */
public String label() {
return label;
}
/** Whether this draft uses the single structured field. */
public boolean structured() {
return structured;
}
}
@@ -0,0 +1,138 @@
package dev.caskeleton.adapter.inbound.web.advanced.release;
import dev.caskeleton.adapter.inbound.web.advanced.WebAdvancedFeature;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Set;
/**
* What one web Advanced capability must show before production.
*
* <p>Per capability, because they fail in unrelated ways and at unrelated scales. Streaming fails
* at connection count; virtual threads fail when a synchronized block pins a carrier under load;
* XML fails on one crafted document. Evidence for any of them is not evidence for the others.
*
* <p>{@code stableBehaviourUnchanged} is the one condition every capability shares, and it is what
* the rollback test exists to establish. If turning the feature off does not restore Stable
* behaviour exactly, then the feature was never optional and every deployment has it.
*
* @param requiredSuites the suites that must have passed
* @param minimumSoak how long it must run under production-like load
* @param securityReviewed whether a security review was done, where the capability needs one
* @param rollbackValidated whether disabling it was actually exercised
* @param stableBehaviourUnchanged whether Stable behaviour is identical with the feature off
*/
public record WebAdvancedPromotionGate(
Set<String> requiredSuites,
Duration minimumSoak,
boolean securityReviewed,
boolean rollbackValidated,
boolean stableBehaviourUnchanged) {
public WebAdvancedPromotionGate {
requiredSuites = Set.copyOf(Objects.requireNonNull(requiredSuites, "requiredSuites"));
Objects.requireNonNull(minimumSoak, "minimumSoak");
if (requiredSuites.isEmpty()) {
throw new IllegalArgumentException(
"a gate requiring no suite passes everything, which is worse than no gate because it "
+ "reads as one");
}
if (minimumSoak.isNegative() || minimumSoak.isZero()) {
throw new IllegalArgumentException(
"a zero soak promotes on the strength of a green build; every failure mode these "
+ "capabilities have needs load or time to appear");
}
}
/** The gate for a capability, with the suites its own failure modes need. */
public static WebAdvancedPromotionGate forFeature(WebAdvancedFeature feature) {
Objects.requireNonNull(feature, "feature");
return new WebAdvancedPromotionGate(suitesFor(feature), soakFor(feature), false, false, false);
}
private static Set<String> suitesFor(WebAdvancedFeature feature) {
return switch (feature) {
case MVC_VIRTUAL_THREADS ->
Set.of("web:test", "webCrossStackParityTest", "virtual-thread-admission", "pinning-jfr");
case WEBFLUX_BLOCKING_BRIDGE ->
Set.of("web:test", "blocking-bridge-bounded", "event-loop-guard");
case JSON_MERGE_PATCH, JSON_PATCH -> Set.of("web:test", "patch-security", "patch-atomicity");
case SSE, NDJSON, JSON_SEQUENCE ->
Set.of(
"web:test",
"streaming-soak-10k",
"slow-consumer-bounded",
"cancellation-propagation",
"pod-drain");
case FUNCTIONAL_WEBFLUX -> Set.of("web:test", "functional-route-parity");
case CBOR, XML -> Set.of("web:test", "codec-security", "codec-budget");
case OPENAPI_32 -> Set.of("web:test", "openapi-32-toolchain-matrix");
case RATELIMIT_DRAFT_HEADERS -> Set.of("web:test", "ratelimit-draft-headers");
};
}
private static Duration soakFor(WebAdvancedFeature feature) {
// Longer where the failure needs sustained load: streaming holds connections, and virtual
// threads only pin under contention that a short run does not produce.
return switch (feature) {
case SSE, NDJSON, JSON_SEQUENCE -> Duration.ofHours(24);
case MVC_VIRTUAL_THREADS, WEBFLUX_BLOCKING_BRIDGE -> Duration.ofHours(24);
default -> Duration.ofHours(8);
};
}
/** Whether this capability needs a security review before it ships. */
public static boolean needsSecurityReview(WebAdvancedFeature feature) {
// Each of these takes attacker-supplied bytes into a new parser or lets a caller address
// arbitrary parts of a resource.
return feature == WebAdvancedFeature.XML
|| feature == WebAdvancedFeature.CBOR
|| feature == WebAdvancedFeature.JSON_PATCH
|| feature == WebAdvancedFeature.JSON_MERGE_PATCH;
}
/**
* Why this may not be promoted yet.
*
* @param feature which capability
* @param passedSuites which suites actually passed
* @param observedSoak how long it ran under production-like load
*/
public List<String> blockers(
WebAdvancedFeature feature, Set<String> passedSuites, Duration observedSoak) {
Objects.requireNonNull(feature, "feature");
Objects.requireNonNull(passedSuites, "passedSuites");
Objects.requireNonNull(observedSoak, "observedSoak");
List<String> blockers = new ArrayList<>();
List<String> missing =
requiredSuites.stream().filter(suite -> !passedSuites.contains(suite)).sorted().toList();
if (!missing.isEmpty()) {
blockers.add("suites not passed: " + missing);
}
if (observedSoak.compareTo(minimumSoak) < 0) {
blockers.add("soak " + observedSoak + " is short of the required " + minimumSoak);
}
if (needsSecurityReview(feature) && !securityReviewed) {
blockers.add(
"no security review; this capability takes attacker-supplied bytes into a parser or "
+ "lets a caller address arbitrary parts of a resource");
}
if (!rollbackValidated) {
blockers.add("rollback not exercised; a flag nobody has turned off is not known to turn off");
}
if (!stableBehaviourUnchanged) {
blockers.add(
"Stable behaviour is not identical with the feature off, so the feature was never "
+ "optional and every deployment has it");
}
return List.copyOf(blockers);
}
/** Whether promotion may proceed. */
public boolean promotable(
WebAdvancedFeature feature, Set<String> passedSuites, Duration observedSoak) {
return blockers(feature, passedSuites, observedSoak).isEmpty();
}
}
@@ -0,0 +1,90 @@
package dev.caskeleton.adapter.inbound.web.advanced.release;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
* The evidence a release claims to have, and the evidence a release needs.
*
* <p>Two separate things, deliberately, because the gap between them is the finding. A manifest
* that only listed what was collected would always look complete — it lists what it lists. The
* required set is written down independently so that missing evidence is a diff rather than an
* absence nobody notices.
*/
public final class WebAdvancedReleaseManifest {
/** Evidence every Advanced release must carry, whatever is enabled. */
public static final List<String> REQUIRED =
List.of(
"stable-release-baseline",
"streaming-soak-10k",
"slow-consumer-bounded",
"cancellation-propagation",
"patch-security",
"codec-security",
"rollback-disabled-profile");
private final Map<String, String> evidence;
private WebAdvancedReleaseManifest(Map<String, String> evidence) {
this.evidence = Map.copyOf(evidence);
}
/** Start an empty manifest. */
public static Builder builder() {
return new Builder();
}
/** Whether a named piece of evidence was recorded. */
public boolean has(String name) {
return evidence.containsKey(name);
}
/** Where a piece of evidence came from. */
public java.util.Optional<String> reference(String name) {
return java.util.Optional.ofNullable(evidence.get(name));
}
/** What is required and absent. */
public List<String> missing() {
return REQUIRED.stream().filter(name -> !has(name)).toList();
}
/** Whether the manifest is complete. */
public boolean complete() {
return missing().isEmpty();
}
/** Collects evidence references. */
public static final class Builder {
private final Map<String, String> evidence = new LinkedHashMap<>();
private Builder() {}
/**
* Record one piece of evidence.
*
* @param name what it is
* @param reference where to find it — a workflow run, a report path, a review link
*/
public Builder record(String name, String reference) {
Objects.requireNonNull(name, "name");
Objects.requireNonNull(reference, "reference");
if (reference.isBlank()) {
throw new IllegalArgumentException(
"evidence needs somewhere to look; a name with no reference is a claim, and a manifest "
+ "of claims is what this exists instead of");
}
evidence.put(name, reference);
return this;
}
/** Freeze it. */
public WebAdvancedReleaseManifest build() {
return new WebAdvancedReleaseManifest(evidence);
}
}
}
@@ -0,0 +1,27 @@
package dev.caskeleton.adapter.inbound.web.advanced.stream;
import java.util.Objects;
import java.util.regex.Pattern;
/**
* One stream's identity, as it appears to the client.
*
* <p>Constrained rather than free, because it is echoed in every envelope and reaches logs and
* metric tags. An unconstrained identifier there is an unbounded-cardinality tag and, if it came
* from the client, an injection point into whatever reads the log.
*
* @param value the identifier
*/
public record StreamId(String value) {
private static final Pattern GRAMMAR = Pattern.compile("[a-zA-Z0-9][a-zA-Z0-9._:-]{0,63}");
public StreamId {
Objects.requireNonNull(value, "value");
if (!GRAMMAR.matcher(value).matches()) {
throw new IllegalArgumentException(
"a stream id must be 1..64 characters of [a-zA-Z0-9._:-] starting alphanumeric; it is "
+ "echoed into logs and metric tags, so it cannot be free text");
}
}
}
@@ -0,0 +1,35 @@
package dev.caskeleton.adapter.inbound.web.advanced.stream;
/**
* Where an item sits in its stream.
*
* <p>Counting from 1, so that 0 is not a valid position and cannot be produced by an uninitialised
* field. A stream whose first item claims position 0 and one whose sequence was never set look
* identical, and the client cannot tell whether it missed something.
*
* @param value the position
*/
public record StreamSequence(long value) implements Comparable<StreamSequence> {
public StreamSequence {
if (value < 1) {
throw new IllegalArgumentException(
"stream positions count from 1, so an unset field cannot pass for the first item");
}
}
/** The first position. */
public static StreamSequence first() {
return new StreamSequence(1);
}
/** The position after this one. */
public StreamSequence next() {
return new StreamSequence(value + 1);
}
@Override
public int compareTo(StreamSequence other) {
return Long.compare(value, other.value);
}
}
@@ -0,0 +1,43 @@
package dev.caskeleton.adapter.inbound.web.advanced.stream;
import dev.caskeleton.adapter.inbound.web.error.ProblemCode;
import java.util.Objects;
import java.util.Optional;
/**
* What to do about a failure, given whether the response has been committed.
*
* <p>Exactly one of the two is present, and the invariant is checked. A decision carrying both
* would let a caller write a problem document *and* a terminal record, which is the half-stream
* half-JSON body that started this.
*
* @param termination how the stream ends, absent when the response can still become an HTTP error
* @param httpStatusChange the problem code to answer with, absent once committed
* @param safeMessage client-safe text for the terminal record
*/
public record TerminationDecision(
Optional<WebStreamTermination> termination,
Optional<ProblemCode> httpStatusChange,
String safeMessage) {
public TerminationDecision {
Objects.requireNonNull(termination, "termination");
Objects.requireNonNull(httpStatusChange, "httpStatusChange");
Objects.requireNonNull(safeMessage, "safeMessage");
if (termination.isPresent() == httpStatusChange.isPresent()) {
throw new IllegalArgumentException(
"a failure is answered either as an HTTP problem or as a terminal stream record, never "
+ "both and never neither");
}
}
/** Nothing was written yet, so this can still be an ordinary error response. */
public static TerminationDecision problem(ProblemCode code, String safeMessage) {
return new TerminationDecision(Optional.empty(), Optional.of(code), safeMessage);
}
/** The response is committed; the failure goes into the stream. */
public static TerminationDecision stream(WebStreamTermination how, String safeMessage) {
return new TerminationDecision(Optional.of(how), Optional.empty(), safeMessage);
}
}
@@ -0,0 +1,41 @@
package dev.caskeleton.adapter.inbound.web.advanced.stream;
import java.util.Objects;
/**
* A stream failed after its headers were written.
*
* <p>Distinct from every other failure in the leaf because the usual remedy is unavailable: the
* status is already 200 and cannot be changed. Throwing this rather than a general exception is how
* the error handler knows not to try — an exception handler that attempts to write a problem
* document onto a committed response produces a body that is half stream and half JSON, which no
* client can parse.
*/
public final class WebPartialResponseException extends RuntimeException {
private static final long serialVersionUID = 1L;
private final transient StreamId streamId;
private final transient long deliveredCount;
/**
* @param streamId which stream
* @param deliveredCount how many items the client already has
* @param cause what failed
*/
public WebPartialResponseException(StreamId streamId, long deliveredCount, Throwable cause) {
super("stream failed after " + deliveredCount + " items were already delivered", cause);
this.streamId = Objects.requireNonNull(streamId, "streamId");
this.deliveredCount = deliveredCount;
}
/** Which stream. */
public StreamId streamId() {
return streamId;
}
/** How many items the client already has, which is what makes this unrecoverable. */
public long deliveredCount() {
return deliveredCount;
}
}
@@ -0,0 +1,83 @@
package dev.caskeleton.adapter.inbound.web.advanced.stream;
import java.time.Duration;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.LongSupplier;
/**
* Shuts streams down in the order that does not produce a thundering herd.
*
* <p>Three steps, and each is there because skipping it produces a specific failure.
*
* <p>**Stop accepting first.** If readiness stays up while streams are draining, the load balancer
* keeps sending new ones and the drain never finishes — the node ends up killed by the deadline
* with streams still open, which is the ungraceful shutdown the drain was meant to avoid.
*
* <p>**Ask before cutting.** A client told to reconnect while the server can still serve it goes to
* another node in an orderly way. A client whose socket is cut retries immediately, and if every
* client's socket is cut at the same instant, every client retries at the same instant.
*
* <p>**Then cut anyway.** A drain without a deadline is a shutdown that never completes, because
* there is always one client that does not reconnect.
*/
public final class WebStreamDrainCoordinator {
private final WebStreamRegistry registry;
private final LongSupplier clockMillis;
private final AtomicBoolean accepting = new AtomicBoolean(true);
/**
* @param registry the streams to drain
* @param clockMillis the clock, supplied so the deadline is testable without sleeping
*/
public WebStreamDrainCoordinator(WebStreamRegistry registry, LongSupplier clockMillis) {
this.registry = Objects.requireNonNull(registry, "registry");
this.clockMillis = Objects.requireNonNull(clockMillis, "clockMillis");
}
/** Whether new streams are still admitted. */
public boolean accepting() {
return accepting.get();
}
/**
* Run the drain.
*
* @param deadline how long to wait before forcing the remainder closed
* @param pollInterval how often to re-check, in millis of the supplied clock
* @return how many streams had to be forced
*/
public int beginDrain(Duration deadline, long pollInterval) {
Objects.requireNonNull(deadline, "deadline");
accepting.set(false);
registry.sessions().forEach(WebStreamSession::requestReconnect);
long expiry = clockMillis.getAsLong() + deadline.toMillis();
while (registry.activeStreams() > 0 && clockMillis.getAsLong() < expiry) {
awaitPoll(pollInterval);
}
int remaining = registry.activeStreams();
registry
.sessions()
.forEach(
session -> {
session.forceClose(WebStreamTermination.ABRUPT_CLOSE);
registry.deregister(session.streamId());
});
return remaining;
}
private static void awaitPoll(long pollInterval) {
if (pollInterval <= 0) {
return;
}
try {
Thread.sleep(pollInterval);
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
// Stop waiting. An interrupted drain proceeds to the forced close rather than looping,
// because the interrupt is the container saying the deadline it gave us has passed.
throw new IllegalStateException("drain interrupted", interrupted);
}
}
}
@@ -0,0 +1,68 @@
package dev.caskeleton.adapter.inbound.web.advanced.stream;
import dev.caskeleton.adapter.inbound.web.error.ProblemCode;
import java.util.Objects;
/**
* What can appear on a stream.
*
* <p>Sealed and three-way, because the client has to be able to tell three outcomes apart and the
* transport cannot tell it. Once response headers are written the HTTP status is fixed at 200, so a
* stream that ends because a dependency failed and one that ends because it finished look the same
* at the transport layer — both are a closed connection after a 200. The difference has to be *in
* the stream*, which is what {@link Failure} and {@link Complete} are for.
*
* <p>A client that sees neither has been cut off mid-stream. That is the third outcome and it is
* the one that must not be silently treated as completion.
*
* @param <T> the item type
*/
public sealed interface WebStreamEnvelope<T> {
/** Which stream this belongs to. */
StreamId streamId();
/** One item. */
record Item<T>(StreamId streamId, StreamSequence sequence, T data)
implements WebStreamEnvelope<T> {
public Item {
Objects.requireNonNull(streamId, "streamId");
Objects.requireNonNull(sequence, "sequence");
Objects.requireNonNull(data, "data");
}
}
/**
* The stream ended because something failed.
*
* <p>Carries a catalog code rather than a status, because the status is already sent. The message
* is client-safe text — a stream error reaches the client with no problem-detail sanitiser in the
* path, so whatever is put here is what the client sees.
*/
record Failure<T>(StreamId streamId, StreamSequence sequence, ProblemCode code, String message)
implements WebStreamEnvelope<T> {
public Failure {
Objects.requireNonNull(streamId, "streamId");
Objects.requireNonNull(sequence, "sequence");
Objects.requireNonNull(code, "code");
Objects.requireNonNull(message, "message");
}
}
/** The stream ended because it finished. */
record Complete<T>(StreamId streamId, StreamSequence lastSequence)
implements WebStreamEnvelope<T> {
public Complete {
Objects.requireNonNull(streamId, "streamId");
Objects.requireNonNull(lastSequence, "lastSequence");
}
}
/** Whether this envelope ends the stream. */
default boolean terminal() {
return this instanceof Failure<T> || this instanceof Complete<T>;
}
}
@@ -0,0 +1,58 @@
package dev.caskeleton.adapter.inbound.web.advanced.stream;
import java.util.Objects;
/**
* Whether a stream that has already delivered items may retry, and what the client should do.
*
* <p>The question this answers is not "was there an error" but "can anything still be done about
* it". A stream that failed before its first item is an ordinary request that failed and can be
* retried whole; one that failed after item 400 cannot, because a retry re-delivers items 1 to 400
* and the client has no way to know they are repeats unless it tracked positions.
*
* @param resumable whether the client may reconnect with a position and continue
* @param retryWholeStream whether the client should start the stream again from the beginning
*/
public record WebStreamErrorPolicy(boolean resumable, boolean retryWholeStream) {
public WebStreamErrorPolicy {
if (resumable && retryWholeStream) {
throw new IllegalArgumentException(
"a resumable stream must not also be retried whole; doing both re-delivers everything "
+ "the client already has and it cannot tell the repeats apart");
}
}
/** No replay source: the client starts over. */
public static WebStreamErrorPolicy startOver() {
return new WebStreamErrorPolicy(false, true);
}
/** A replay source exists: the client reconnects with its last position. */
public static WebStreamErrorPolicy resumeFromPosition() {
return new WebStreamErrorPolicy(true, false);
}
/** Nothing the client can do; the stream is over. */
public static WebStreamErrorPolicy nothingToDo() {
return new WebStreamErrorPolicy(false, false);
}
/**
* What the client should be told to do after this termination.
*
* @param termination how the stream ended
* @param delivered how many items reached the client
*/
public WebStreamErrorPolicy afterDelivering(WebStreamTermination termination, long delivered) {
Objects.requireNonNull(termination, "termination");
if (termination == WebStreamTermination.NORMAL_COMPLETE) {
return nothingToDo();
}
if (delivered == 0) {
// Nothing was delivered, so a fresh attempt costs nothing and duplicates nothing.
return startOver();
}
return resumable ? resumeFromPosition() : nothingToDo();
}
}
@@ -0,0 +1,135 @@
package dev.caskeleton.adapter.inbound.web.advanced.stream;
import java.time.Instant;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.locks.ReentrantLock;
/**
* What a stream actually delivered, as opposed to what it was asked to.
*
* <p>The reason this exists rather than a counter: a stream's HTTP status is 200 from the moment
* headers are written, so the response tells you nothing about whether it worked. Without a record
* of what was delivered and how it ended, a stream that emitted three items and died is
* indistinguishable in every log and metric from one that emitted three items because there were
* three.
*
* <p>Monotonicity is enforced rather than assumed. A repeated or regressing position means the
* source produced a duplicate or the writer retried, and a client using the position to deduplicate
* would silently drop the second item.
*/
public final class WebStreamEvidence {
private final StreamId streamId;
private final Instant startedAt;
private final ReentrantLock lock = new ReentrantLock();
private StreamSequence lastDelivered;
private long deliveredCount;
private WebStreamTermination termination;
private Instant endedAt;
public WebStreamEvidence(StreamId streamId, Instant startedAt) {
this.streamId = Objects.requireNonNull(streamId, "streamId");
this.startedAt = Objects.requireNonNull(startedAt, "startedAt");
}
/** Which stream. */
public StreamId streamId() {
return streamId;
}
/**
* Record that an item reached the transport.
*
* @throws IllegalStateException if the position does not advance, or the stream already ended
*/
public void recordDelivered(StreamSequence sequence) {
Objects.requireNonNull(sequence, "sequence");
lock.lock();
try {
if (termination != null) {
throw new IllegalStateException(
"an item was delivered after the stream ended as "
+ termination
+ "; the terminal envelope has already told the client there is nothing more");
}
if (lastDelivered != null && sequence.compareTo(lastDelivered) <= 0) {
throw new IllegalStateException(
"stream position went from "
+ lastDelivered.value()
+ " to "
+ sequence.value()
+ "; a client deduplicating on position would silently drop this item");
}
lastDelivered = sequence;
deliveredCount++;
} finally {
lock.unlock();
}
}
/** Record how the stream ended. The first call wins; the end happens once. */
public void recordTermination(WebStreamTermination how, Instant at) {
Objects.requireNonNull(how, "how");
Objects.requireNonNull(at, "at");
lock.lock();
try {
if (termination == null) {
termination = how;
endedAt = at;
}
} finally {
lock.unlock();
}
}
/** How many items reached the transport. */
public long deliveredCount() {
lock.lock();
try {
return deliveredCount;
} finally {
lock.unlock();
}
}
/** The last position delivered, absent when nothing was. */
public Optional<StreamSequence> lastDelivered() {
lock.lock();
try {
return Optional.ofNullable(lastDelivered);
} finally {
lock.unlock();
}
}
/** How it ended, absent while it is still running. */
public Optional<WebStreamTermination> termination() {
lock.lock();
try {
return Optional.ofNullable(termination);
} finally {
lock.unlock();
}
}
/**
* Whether the client can tell the stream is finished.
*
* <p>False for an abrupt close, which is the case worth counting: the client sees a closed
* connection after a 200 and has no way to distinguish that from a completed stream.
*/
public boolean clientKnowsItEnded() {
return termination().map(WebStreamTermination::clientWasTold).orElse(false);
}
/** How long it ran, so far or in total. */
public java.time.Duration ageAt(Instant now) {
lock.lock();
try {
return java.time.Duration.between(startedAt, endedAt == null ? now : endedAt);
} finally {
lock.unlock();
}
}
}
@@ -0,0 +1,74 @@
package dev.caskeleton.adapter.inbound.web.advanced.stream;
import java.time.Duration;
import java.util.Objects;
/**
* The bounds one stream runs under.
*
* <p>Every field here is a bound whose absence is invisible until production. A stream with no
* maximum age is a connection held for as long as a client cares to hold it; with no heartbeat, a
* disconnected client is indistinguishable from a quiet one and the server keeps its resources
* forever; with no buffer bound, a slow consumer's backlog is held in the server's heap.
*
* @param heartbeatInterval how often to write a keepalive when there is nothing else to send
* @param idleTimeout how long a stream may produce nothing before it is closed
* @param maxStreamAge the longest a stream may live regardless of activity
* @param maxBufferedItems how many items may await a slow consumer before the stream is closed
* @param maxItemBytes the ceiling on one serialized item
*/
public record WebStreamPolicy(
Duration heartbeatInterval,
Duration idleTimeout,
Duration maxStreamAge,
int maxBufferedItems,
int maxItemBytes) {
public WebStreamPolicy {
Objects.requireNonNull(heartbeatInterval, "heartbeatInterval");
Objects.requireNonNull(idleTimeout, "idleTimeout");
Objects.requireNonNull(maxStreamAge, "maxStreamAge");
requirePositive(heartbeatInterval, "heartbeatInterval");
requirePositive(idleTimeout, "idleTimeout");
requirePositive(maxStreamAge, "maxStreamAge");
if (heartbeatInterval.compareTo(idleTimeout) >= 0) {
throw new IllegalArgumentException(
"the heartbeat must be shorter than the idle timeout, or the server times out its own "
+ "healthy streams between beats");
}
if (idleTimeout.compareTo(maxStreamAge) > 0) {
throw new IllegalArgumentException(
"an idle timeout longer than the maximum age is unreachable, so the stream never closes "
+ "for idleness and the setting reads as protection that is not there");
}
if (maxBufferedItems < 1) {
throw new IllegalArgumentException(
"a stream with no buffer allowance cannot absorb a single slow write");
}
if (maxItemBytes < 1) {
throw new IllegalArgumentException("an item ceiling of zero admits nothing");
}
}
/** A conventional profile: 15s heartbeat, 60s idle, 30 minutes, 256 items, 256KB each. */
public static WebStreamPolicy conventional() {
return new WebStreamPolicy(
Duration.ofSeconds(15), Duration.ofSeconds(60), Duration.ofMinutes(30), 256, 262_144);
}
/** Whether a serialized item may be written. */
public boolean itemWithinBounds(int itemBytes) {
return itemBytes >= 0 && itemBytes <= maxItemBytes;
}
/** Whether another item may be buffered for a consumer that is behind. */
public boolean mayBuffer(int currentlyBuffered) {
return currentlyBuffered < maxBufferedItems;
}
private static void requirePositive(Duration value, String field) {
if (value.isNegative() || value.isZero()) {
throw new IllegalArgumentException(field + " must be positive");
}
}
}
@@ -0,0 +1,92 @@
package dev.caskeleton.adapter.inbound.web.advanced.stream;
import java.time.Duration;
import java.time.Instant;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
/**
* Every stream currently open on this node.
*
* <p>The reason a registry is needed at all: a stream is a request that has not finished, so the
* usual "in-flight requests" accounting does not bound it — a node with a hundred open streams and
* no other traffic looks idle by request rate and is holding a hundred connections, a hundred
* buffers and whatever each of their sources is subscribed to.
*
* <p>It is also the only place a shutdown can find them. Without it, a drain has nothing to drain
* and the container's graceful shutdown closes the sockets, which the clients see as an abrupt
* failure rather than an orderly reconnect.
*/
public final class WebStreamRegistry {
private final Map<StreamId, WebStreamSession> sessions = new ConcurrentHashMap<>();
private final int maxConcurrentStreams;
public WebStreamRegistry(int maxConcurrentStreams) {
if (maxConcurrentStreams < 1) {
throw new IllegalArgumentException("a registry admitting nothing serves no stream");
}
this.maxConcurrentStreams = maxConcurrentStreams;
}
/**
* Admit a stream, or refuse it because the node is full.
*
* @return whether it was admitted
*/
public boolean register(WebStreamSession session) {
Objects.requireNonNull(session, "session");
// Checked and inserted under computeIfAbsent so two concurrent admissions cannot both see room
// for the last slot.
boolean[] admitted = new boolean[1];
sessions.compute(
session.streamId(),
(id, existing) -> {
if (existing != null) {
throw new IllegalStateException(
"two streams registered as "
+ id.value()
+ "; one would be unreachable to a drain");
}
if (sessions.size() >= maxConcurrentStreams) {
admitted[0] = false;
return null;
}
admitted[0] = true;
return session;
});
return admitted[0];
}
/** Forget a stream that has ended. */
public boolean deregister(StreamId streamId) {
return sessions.remove(streamId) != null;
}
/** Every open stream. */
public Collection<WebStreamSession> sessions() {
return List.copyOf(sessions.values());
}
/** How many are open. */
public int activeStreams() {
return sessions.size();
}
/** The ceiling. */
public int maxConcurrentStreams() {
return maxConcurrentStreams;
}
/** Streams that have exceeded the policy's maximum age. */
public List<WebStreamSession> olderThan(Duration maxAge, Instant now) {
Objects.requireNonNull(maxAge, "maxAge");
Objects.requireNonNull(now, "now");
return sessions.values().stream()
.filter(session -> !now.isBefore(session.startedAt().plus(maxAge)))
.toList();
}
}
@@ -0,0 +1,34 @@
package dev.caskeleton.adapter.inbound.web.advanced.stream;
import java.time.Instant;
/**
* One live stream, as the registry sees it.
*
* <p>An interface rather than a class because the two stacks hold entirely different things — an
* {@code SseEmitter} on MVC, a subscription on WebFlux — and the registry only needs to be able to
* ask them to stop.
*/
public interface WebStreamSession {
/** Which stream. */
StreamId streamId();
/** When it started. */
Instant startedAt();
/**
* Ask the client to reconnect.
*
* <p>Sent before a drain deadline rather than at it. A client told to reconnect while the server
* is still able to serve it goes somewhere else in an orderly way; a client whose connection is
* cut at the deadline retries immediately, and every one of them retries at the same moment.
*/
void requestReconnect();
/** Close it now, whether or not the client is ready. */
void forceClose(WebStreamTermination reason);
/** Whether it is still open. */
boolean open();
}
@@ -0,0 +1,45 @@
package dev.caskeleton.adapter.inbound.web.advanced.stream;
/**
* How a stream ended.
*
* <p>Six outcomes, and the reason for the count is that "the connection closed" is the observable
* form of all of them. Distinguishing them is only possible if the server records which one it
* caused, because from the socket they are identical.
*/
public enum WebStreamTermination {
/** The source finished and a {@code Complete} envelope was written. */
NORMAL_COMPLETE,
/** Something failed after commit, and an {@code Error} envelope was written. */
TERMINAL_ERROR_RECORD,
/** The client went away. Not a fault. */
CLIENT_DISCONNECTED,
/** Nothing was sent for longer than the policy allows. */
IDLE_TIMEOUT,
/** The stream reached its maximum age. */
MAX_AGE,
/**
* The connection failed before a terminal envelope could be written.
*
* <p>The one outcome where the client cannot know the stream is over rather than merely quiet.
* Recorded explicitly so it does not get counted as a normal completion in the metrics, which is
* where a rising rate of mid-stream failures would otherwise hide.
*/
ABRUPT_CLOSE;
/** Whether the client received an explicit end. */
public boolean clientWasTold() {
return this == NORMAL_COMPLETE || this == TERMINAL_ERROR_RECORD;
}
/** Whether this indicates a server-side fault. */
public boolean serverFault() {
return this == TERMINAL_ERROR_RECORD || this == ABRUPT_CLOSE;
}
}
@@ -0,0 +1,53 @@
package dev.caskeleton.adapter.inbound.web.advanced.stream;
import dev.caskeleton.adapter.inbound.web.error.ProblemCode;
import java.util.Objects;
/**
* Decides how a stream failure is reported, based on whether anything has been written yet.
*
* <p>The whole class is one branch, and the branch is the contract. Before commit a stream failure
* is an ordinary error and gets an RFC 9457 problem document with a real status. After commit the
* status is 200 and cannot be changed — attempting it produces a response whose body is a stream
* followed by a JSON object, which no client parses and every proxy caches as a success.
*
* <p>The message is deliberately generic. A terminal stream record reaches the client without the
* problem-detail sanitiser in the path, so an exception message put here goes straight out.
*/
public final class WebStreamTerminationMapper {
private static final String COMMITTED_MESSAGE =
"the stream ended early because a dependency failed";
/** The standard mapper. Stateless; there is nothing to configure. */
public static WebStreamTerminationMapper standard() {
return new WebStreamTerminationMapper();
}
/**
* Map a failure.
*
* @param responseCommitted whether any byte of the response has been written
* @param failure what went wrong
*/
public TerminationDecision mapFailure(boolean responseCommitted, Throwable failure) {
Objects.requireNonNull(failure, "failure");
if (!responseCommitted) {
return TerminationDecision.problem(ProblemCode.DEPENDENCY_FAILURE, COMMITTED_MESSAGE);
}
return TerminationDecision.stream(
WebStreamTermination.TERMINAL_ERROR_RECORD, COMMITTED_MESSAGE);
}
/**
* Map a failure that could not even write a terminal record.
*
* <p>Separate because the client learns nothing at all: it sees a connection closed after a 200.
* Counting these as normal completions is how a rising rate of mid-stream failures stays
* invisible.
*/
public TerminationDecision mapWriteFailure() {
return TerminationDecision.stream(
WebStreamTermination.ABRUPT_CLOSE, "the connection failed before the stream could end");
}
}
@@ -0,0 +1,68 @@
package dev.caskeleton.adapter.inbound.web.advanced.stream.encoding;
import java.nio.charset.StandardCharsets;
import java.util.Objects;
/**
* RFC 7464 framing: a record separator before each record, a line feed after it.
*
* <p>The reason this format exists, and the reason it is worth the extra byte over NDJSON: the
* separator comes *first*. A truncated record leaves a partial value with no trailing newline, and
* the next {@code 0x1E} unambiguously starts the next one — so a parser resynchronises at the next
* record rather than trying to parse the truncation joined to what follows. NDJSON cannot do that,
* because its delimiter is also the thing that got truncated away.
*
* <p>This matters for a stream specifically. A truncated NDJSON line is not a hypothetical: it is
* what a client reads when the connection dies mid-write, which for a long-lived stream is the
* normal way it ends.
*/
public final class JsonSequenceFraming {
/** The RFC 7464 record separator. */
public static final byte RECORD_SEPARATOR = 0x1E;
/** The trailing line feed. */
public static final byte LINE_FEED = 0x0A;
private JsonSequenceFraming() {}
/**
* Frame one already-serialized JSON value.
*
* @param json the serialized value, without framing
*/
public static byte[] frame(byte[] json) {
Objects.requireNonNull(json, "json");
byte[] framed = new byte[json.length + 2];
framed[0] = RECORD_SEPARATOR;
System.arraycopy(json, 0, framed, 1, json.length);
framed[framed.length - 1] = LINE_FEED;
return framed;
}
/**
* Whether a byte sequence is correctly framed.
*
* <p>Used by the contract tests rather than by the writer. A writer that checks its own output is
* checking that its own two lines of array copying work; the value is in a consumer-side check
* that the bytes on the wire are what a third-party parser needs.
*/
public static boolean framed(byte[] candidate) {
Objects.requireNonNull(candidate, "candidate");
return candidate.length >= 2
&& candidate[0] == RECORD_SEPARATOR
&& candidate[candidate.length - 1] == LINE_FEED;
}
/**
* The payload inside a framed record.
*
* @throws IllegalArgumentException if it is not framed
*/
public static String payload(byte[] framed) {
if (!framed(framed)) {
throw new IllegalArgumentException("not an RFC 7464 record");
}
return new String(framed, 1, framed.length - 2, StandardCharsets.UTF_8);
}
}
@@ -0,0 +1,56 @@
package dev.caskeleton.adapter.inbound.web.advanced.stream.encoding;
import java.nio.charset.StandardCharsets;
import java.util.Objects;
/**
* NDJSON framing: one JSON value per line, exactly one line feed after each.
*
* <p>Two rules, and both are places a writer goes wrong. A record containing a raw newline breaks
* the framing for everything after it, because the consumer's line split is the only boundary there
* is — so the serializer must not pretty-print, and this checks that it did not. A missing trailing
* newline on the last record leaves a consumer blocked waiting for a line that never arrives.
*/
public final class NdjsonFraming {
/** The line delimiter. */
public static final byte LINE_FEED = 0x0A;
private NdjsonFraming() {}
/**
* Frame one already-serialized JSON value.
*
* @param json the serialized value, which must contain no line feed
*/
public static byte[] frame(byte[] json) {
Objects.requireNonNull(json, "json");
for (byte b : json) {
if (b == LINE_FEED) {
throw new IllegalArgumentException(
"an NDJSON record cannot contain a line feed; the consumer's line split is the only "
+ "record boundary there is, so one pretty-printed value breaks every record "
+ "after it");
}
}
byte[] framed = new byte[json.length + 1];
System.arraycopy(json, 0, framed, 0, json.length);
framed[json.length] = LINE_FEED;
return framed;
}
/** Whether a whole response body is correctly framed. */
public static boolean framed(byte[] body) {
Objects.requireNonNull(body, "body");
return body.length > 0 && body[body.length - 1] == LINE_FEED;
}
/** How many records a body holds. */
public static long recordCount(byte[] body) {
if (!framed(body)) {
throw new IllegalArgumentException(
"an unterminated NDJSON body leaves the consumer waiting for a line that never arrives");
}
return new String(body, StandardCharsets.UTF_8).lines().count();
}
}
@@ -0,0 +1,61 @@
package dev.caskeleton.adapter.inbound.web.advanced.stream.encoding;
import java.util.Objects;
/**
* One line of an {@code application/x-ndjson} response.
*
* <p>Separate from {@code WebStreamEnvelope} rather than reusing it, because the two answer
* different questions. The envelope is what the platform knows about an item; this is what goes on
* the wire, and it is flat and small on purpose — an NDJSON consumer parses one line at a time with
* no framing beyond the newline, so anything nested here is something a client has to unwrap before
* it can tell an item from a terminal record.
*
* @param <T> the item type
*/
public sealed interface NdjsonRecord<T> {
/** One item. */
record Item<T>(long sequence, T data) implements NdjsonRecord<T> {
public Item {
if (sequence < 1) {
throw new IllegalArgumentException("stream positions count from 1");
}
Objects.requireNonNull(data, "data");
}
}
/** The stream ended because something failed. */
record Failure<T>(long sequence, String code, String message) implements NdjsonRecord<T> {
public Failure {
Objects.requireNonNull(code, "code");
Objects.requireNonNull(message, "message");
}
}
/**
* The stream ended because it finished.
*
* <p>Written even when the stream is empty. A zero-byte NDJSON body and a connection that failed
* before the first line are the same thing to a client, and the completion marker is what
* separates them.
*/
record Complete<T>(long lastSequence) implements NdjsonRecord<T> {}
/** One item, at a position. */
static <T> NdjsonRecord<T> item(long sequence, T data) {
return new Item<>(sequence, data);
}
/** The terminal marker. */
static <T> NdjsonRecord<T> complete(long lastSequence) {
return new Complete<>(lastSequence);
}
/** Whether this ends the stream. */
default boolean terminal() {
return this instanceof Failure<T> || this instanceof Complete<T>;
}
}
@@ -0,0 +1,34 @@
package dev.caskeleton.adapter.inbound.web.advanced.stream.encoding;
/**
* Which record framing a streaming response uses.
*
* <p>Here rather than on either writer, because the two runtimes are mutually exclusive and neither
* may name the other. A framing enum living on the servlet writer would make the reactive one
* depend on it, which is the edge the whole MVC/WebFlux separation exists to prevent — and it would
* be an edge created by an enum, not by any actual coupling.
*/
public enum StreamFraming {
/** One JSON value per line. */
NDJSON(StreamMediaType.NDJSON),
/** RFC 7464: a record separator before each value, a line feed after. */
JSON_SEQUENCE(StreamMediaType.JSON_SEQ);
private final String mediaType;
StreamFraming(String mediaType) {
this.mediaType = mediaType;
}
/** The media type this framing is served as. */
public String mediaType() {
return mediaType;
}
/** Frame one already-serialized value. */
public byte[] frame(byte[] json) {
return this == NDJSON ? NdjsonFraming.frame(json) : JsonSequenceFraming.frame(json);
}
}
@@ -0,0 +1,33 @@
package dev.caskeleton.adapter.inbound.web.advanced.stream.encoding;
/**
* One stream item exceeded the policy's per-item ceiling.
*
* <p>Thrown before any byte of it is framed, so the stream can end with a terminal error record
* rather than a truncated one. The alternative — discovering the size after the separator is
* written — leaves the consumer resynchronising past a partial record, which is recoverable for
* JSON-seq and not for NDJSON.
*/
public final class StreamItemTooLargeException extends RuntimeException {
private static final long serialVersionUID = 1L;
private final transient int actualBytes;
private final transient int maxBytes;
public StreamItemTooLargeException(int actualBytes, int maxBytes) {
super("a stream item of " + actualBytes + " bytes exceeds the " + maxBytes + " byte ceiling");
this.actualBytes = actualBytes;
this.maxBytes = maxBytes;
}
/** How big it was. */
public int actualBytes() {
return actualBytes;
}
/** What the ceiling is. */
public int maxBytes() {
return maxBytes;
}
}

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