feat: web, websocket 어댑터 추가 구현
This commit is contained in:
@@ -72,6 +72,10 @@ dependencies {
|
||||
// a test-only authentication/CORS composition. Security remains a composition-root concern;
|
||||
// this dependency does not add production security policy to the opt-in GraphQL adapter.
|
||||
testImplementation 'org.springframework.boot:spring-boot-starter-security'
|
||||
// TestRestTemplate needs RestTemplateBuilder, and spring-boot-resttestclient stopped
|
||||
// bringing it transitively in Spring Boot 4.0.x — the capability is still supported, its
|
||||
// dependency is simply no longer implicit. A module that autowires TestRestTemplate says so.
|
||||
testImplementation 'org.springframework.boot:spring-boot-restclient'
|
||||
|
||||
// A real MeterRegistry, so the cardinality claim is measured rather than asserted. Only
|
||||
// micrometer-observation is on the production classpath; a registry that actually stores series
|
||||
|
||||
@@ -2,19 +2,18 @@
|
||||
# Manual edits can break the build and are not advised.
|
||||
# This file is expected to be part of source control.
|
||||
biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,testCompileClasspath,testFixturesCompileClasspath
|
||||
ch.qos.logback:logback-classic:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
ch.qos.logback:logback-core:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
com.fasterxml.jackson.core:jackson-annotations:2.20=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor
|
||||
ch.qos.logback:logback-classic:1.5.38=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
ch.qos.logback:logback-core:1.5.38=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
com.fasterxml.jackson.core:jackson-annotations:2.21=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor
|
||||
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor
|
||||
com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs
|
||||
com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,testCompileClasspath,testFixturesCompileClasspath
|
||||
com.github.spotbugs:spotbugs:4.10.2=spotbugs
|
||||
com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs
|
||||
com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor
|
||||
com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor
|
||||
com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor
|
||||
com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,spotbugs,testCompileClasspath,testFixturesCompileClasspath
|
||||
com.google.code.findbugs:jsr305:3.0.2=checkstyle,spotbugs
|
||||
com.google.code.gson:gson:2.13.2=spotbugs
|
||||
com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor
|
||||
com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,testCompileClasspath,testFixturesCompileClasspath
|
||||
@@ -33,27 +32,27 @@ com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProce
|
||||
com.graphql-java:graphql-java:25.0=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
com.graphql-java:java-dataloader:6.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins
|
||||
com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath
|
||||
com.jayway.jsonpath:json-path:2.10.0=testCompileClasspath,testRuntimeClasspath
|
||||
com.puppycrawl.tools:checkstyle:13.5.0=checkstyle
|
||||
com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath
|
||||
commons-beanutils:commons-beanutils:1.11.0=checkstyle
|
||||
commons-collections:commons-collections:3.2.2=checkstyle
|
||||
commons-io:commons-io:2.21.0=spotbugs
|
||||
commons-logging:commons-logging:1.3.5=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
commons-logging:commons-logging:1.3.6=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
info.picocli:picocli:4.7.7=checkstyle
|
||||
io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor
|
||||
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor
|
||||
io.micrometer:context-propagation:1.2.0=runtimeClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
io.micrometer:micrometer-core:1.16.0=testCompileClasspath,testRuntimeClasspath
|
||||
io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
io.projectreactor:reactor-core:3.8.0=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
io.micrometer:context-propagation:1.2.1=runtimeClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
io.micrometer:micrometer-commons:1.16.7=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
io.micrometer:micrometer-core:1.16.7=testCompileClasspath,testRuntimeClasspath
|
||||
io.micrometer:micrometer-observation:1.16.7=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
io.projectreactor:reactor-core:3.8.7=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
jakarta.activation:jakarta.activation-api:2.1.4=testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
jakarta.servlet:jakarta.servlet-api:6.1.0=compileClasspath
|
||||
jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.xml.bind:jakarta.xml.bind-api:4.0.5=testCompileClasspath,testRuntimeClasspath
|
||||
javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor,testFixturesAnnotationProcessor
|
||||
jaxen:jaxen:2.0.0=spotbugs
|
||||
jaxen:jaxen:2.0.6=spotbugs
|
||||
net.bytebuddy:byte-buddy-agent:1.17.8=testCompileClasspath,testRuntimeClasspath
|
||||
net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath
|
||||
net.minidev:accessors-smart:2.6.0=testCompileClasspath,testRuntimeClasspath
|
||||
@@ -66,19 +65,19 @@ org.apache.commons:commons-text:1.15.0=spotbugs
|
||||
org.apache.commons:commons-text:1.3=checkstyle
|
||||
org.apache.httpcomponents:httpclient:4.5.13=checkstyle
|
||||
org.apache.httpcomponents:httpcore:4.4.16=checkstyle
|
||||
org.apache.logging.log4j:log4j-api:2.25.2=compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
org.apache.logging.log4j:log4j-core:2.25.2=spotbugs
|
||||
org.apache.logging.log4j:log4j-to-slf4j:2.25.2=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
org.apache.logging.log4j:log4j-api:2.25.5=compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
org.apache.logging.log4j:log4j-core:2.25.5=spotbugs
|
||||
org.apache.logging.log4j:log4j-to-slf4j:2.25.5=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
org.apache.maven.doxia:doxia-core:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle
|
||||
org.apache.tomcat.embed:tomcat-embed-core:11.0.14=testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.tomcat.embed:tomcat-embed-el:11.0.14=testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.tomcat.embed:tomcat-embed-core:11.0.24=testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.tomcat.embed:tomcat-embed-el:11.0.24=testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.tomcat.embed:tomcat-embed-websocket:11.0.24=testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.xbean:xbean-reflect:3.7=checkstyle
|
||||
org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath
|
||||
org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath
|
||||
org.assertj:assertj-core:3.27.7=testCompileClasspath,testRuntimeClasspath
|
||||
org.awaitility:awaitility:4.3.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle
|
||||
org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle
|
||||
@@ -88,15 +87,15 @@ org.dom4j:dom4j:2.2.0=spotbugs
|
||||
org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.hdrhistogram:HdrHistogram:2.2.2=testRuntimeClasspath
|
||||
org.javassist:javassist:3.28.0-GA=checkstyle
|
||||
org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testFixturesAnnotationProcessor,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-engine:6.0.1=testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath
|
||||
org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.jspecify:jspecify:1.0.1=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testFixturesAnnotationProcessor,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-api:6.0.3=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-engine:6.0.3=testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-params:6.0.3=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter:6.0.3=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-commons:6.0.3=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-engine:6.0.3=testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-launcher:6.0.3=testRuntimeClasspath
|
||||
org.junit:junit-bom:6.0.3=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit:junit-bom:6.1.0=spotbugs
|
||||
org.latencyutils:LatencyUtils:2.0.3=testRuntimeClasspath
|
||||
org.mockito:mockito-core:5.20.0=mockitoAgent,testCompileClasspath,testRuntimeClasspath
|
||||
@@ -117,61 +116,63 @@ org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor,
|
||||
org.reactivestreams:reactive-streams:1.0.4=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
org.reflections:reflections:0.10.2=checkstyle
|
||||
org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath
|
||||
org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j
|
||||
org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-configuration-processor:4.0.0=annotationProcessor
|
||||
org.springframework.boot:spring-boot-graphql-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-graphql:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-http-codec:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-http-converter:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-jackson:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-restclient:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-security:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-servlet:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-graphql-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-graphql:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-jackson:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-security:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-web:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-web-server:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-webtestclient:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
org.springframework.graphql:spring-graphql-test:2.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.graphql:spring-graphql:2.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
org.springframework.security:spring-security-config:7.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.security:spring-security-core:7.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.security:spring-security-crypto:7.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.security:spring-security-web:7.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-aop:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-beans:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-context:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-core:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-expression:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-test:7.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-web:7.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-webflux:7.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-webmvc:7.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.slf4j:jul-to-slf4j:2.0.18=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
org.slf4j:slf4j-api:2.0.18=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j
|
||||
org.slf4j:slf4j-simple:2.0.18=checkstyle
|
||||
org.springframework.boot:spring-boot-autoconfigure:4.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-configuration-processor:4.0.8=annotationProcessor
|
||||
org.springframework.boot:spring-boot-graphql-test:4.0.8=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-graphql:4.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-http-client:4.0.8=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-http-codec:4.0.8=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-http-converter:4.0.8=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-jackson:4.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-reactor:4.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-restclient:4.0.8=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-resttestclient:4.0.8=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-security:4.0.8=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-servlet:4.0.8=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-graphql-test:4.0.8=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-graphql:4.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-jackson-test:4.0.8=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-jackson:4.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-logging:4.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-security:4.0.8=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-test:4.0.8=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.8=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-tomcat:4.0.8=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-web:4.0.8=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-webmvc-test:4.0.8=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-webmvc:4.0.8=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter:4.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-test-autoconfigure:4.0.8=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-test:4.0.8=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-tomcat:4.0.8=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-web-server:4.0.8=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-webmvc-test:4.0.8=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-webmvc:4.0.8=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-webtestclient:4.0.8=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot:4.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
org.springframework.graphql:spring-graphql-test:2.0.5=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.graphql:spring-graphql:2.0.5=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
org.springframework.security:spring-security-config:7.0.7=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.security:spring-security-core:7.0.7=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.security:spring-security-crypto:7.0.7=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.security:spring-security-web:7.0.7=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-aop:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-beans:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-context:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-core:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-expression:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-test:7.0.9=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-web:7.0.9=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-webflux:7.0.9=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-webmvc:7.0.9=testCompileClasspath,testRuntimeClasspath
|
||||
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
|
||||
org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath
|
||||
org.yaml:snakeyaml:2.5=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
tools.jackson.core:jackson-core:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
tools.jackson.core:jackson-databind:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
tools.jackson:jackson-bom:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
tools.jackson.core:jackson-core:3.1.5=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
tools.jackson.core:jackson-databind:3.1.5=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
tools.jackson:jackson-bom:3.1.5=compileClasspath,runtimeClasspath,testCompileClasspath,testFixturesCompileClasspath,testFixturesRuntimeClasspath,testRuntimeClasspath
|
||||
empty=
|
||||
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql.advanced.rsocket;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap.GraphQlAdvancedCapability;
|
||||
import dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap.GraphQlAdvancedCapabilityDisabledException;
|
||||
import dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap.GraphQlAdvancedFeatureFlags;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Decides whether the experimental RSocket transport may exist, and on what terms.
|
||||
*
|
||||
* <p>Two gates rather than one, and the second is the reason this is a factory. RSocket is graded
|
||||
* experimental here, so the capability flag alone is not enough: the deployment must also have
|
||||
* given explicit experimental approval. A flag can be set by anyone editing configuration; the
|
||||
* approval is a separate act, and separating them is what stops an experimental transport from
|
||||
* being switched on the way a supported one would be.
|
||||
*
|
||||
* <p>The named-consumer list is the third condition, enforced by {@link GraphQlRSocketProperties}
|
||||
* itself. An experimental transport with no named consumer is one nobody will notice breaking,
|
||||
* which is the state it must not be allowed to reach.
|
||||
*/
|
||||
public final class GraphQlRSocketHandlerFactory {
|
||||
|
||||
private final GraphQlAdvancedFeatureFlags flags;
|
||||
private final GraphQlRSocketProperties properties;
|
||||
private final GraphQlRSocketRoutePolicy routes;
|
||||
|
||||
/**
|
||||
* @param flags which capabilities this deployment named, and whether it approved experiments
|
||||
* @param properties the transport settings, including the named consumers
|
||||
* @param routes which RSocket routes reach GraphQL
|
||||
*/
|
||||
public GraphQlRSocketHandlerFactory(
|
||||
GraphQlAdvancedFeatureFlags flags,
|
||||
GraphQlRSocketProperties properties,
|
||||
GraphQlRSocketRoutePolicy routes) {
|
||||
this.flags = Objects.requireNonNull(flags, "flags");
|
||||
this.properties = Objects.requireNonNull(properties, "properties");
|
||||
this.routes = Objects.requireNonNull(routes, "routes");
|
||||
}
|
||||
|
||||
/** Whether the transport may be installed at all. */
|
||||
public boolean enabled() {
|
||||
return properties.enabled() && flags.isEnabled(GraphQlAdvancedCapability.RSOCKET);
|
||||
}
|
||||
|
||||
/**
|
||||
* The settings a handler is installed with.
|
||||
*
|
||||
* @throws GraphQlAdvancedCapabilityDisabledException when the capability was not named, or was
|
||||
* named without experimental approval
|
||||
*/
|
||||
public GraphQlRSocketHandlerSettings settings() {
|
||||
if (!enabled()) {
|
||||
throw new GraphQlAdvancedCapabilityDisabledException(
|
||||
GraphQlAdvancedCapability.RSOCKET.name());
|
||||
}
|
||||
return new GraphQlRSocketHandlerSettings(
|
||||
routes, properties.allowedMetadataMimeTypes(), properties.namedConsumers());
|
||||
}
|
||||
|
||||
/**
|
||||
* What a handler is configured with.
|
||||
*
|
||||
* @param routes which RSocket routes reach GraphQL
|
||||
* @param allowedMetadataMimeTypes the metadata encodings accepted
|
||||
* @param namedConsumers who this transport exists for
|
||||
*/
|
||||
public record GraphQlRSocketHandlerSettings(
|
||||
GraphQlRSocketRoutePolicy routes,
|
||||
Set<String> allowedMetadataMimeTypes,
|
||||
Set<String> namedConsumers) {
|
||||
|
||||
public GraphQlRSocketHandlerSettings {
|
||||
Objects.requireNonNull(routes, "routes");
|
||||
allowedMetadataMimeTypes = Set.copyOf(allowedMetadataMimeTypes);
|
||||
namedConsumers = Set.copyOf(namedConsumers);
|
||||
if (namedConsumers.isEmpty()) {
|
||||
throw new IllegalArgumentException(
|
||||
"an experimental transport with no named consumer is one nobody will notice breaking");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql.advanced.springdata;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* The repository exposures this deployment has actually decided to publish.
|
||||
*
|
||||
* <p>Empty by default, and that is the capability's entire safety property. Spring's
|
||||
* {@code @GraphQlRepository} auto-registers a data fetcher for every annotated repository it finds,
|
||||
* so without an allowlist the set of exposed fields is whatever somebody annotated — which is a
|
||||
* decision made in a persistence class, by whoever was working on persistence, and reviewed as a
|
||||
* persistence change.
|
||||
*
|
||||
* <p>Each registration carries its own argument, pagination and projection policies rather than
|
||||
* sharing global ones. A coordinate that may sort by one column is not the same as one that may
|
||||
* sort by another, and a single shared policy would have to be the union.
|
||||
*/
|
||||
public final class GraphQlRepositoryAllowlist {
|
||||
|
||||
private final Map<String, Registration> registrations;
|
||||
|
||||
private GraphQlRepositoryAllowlist(Map<String, Registration> registrations) {
|
||||
this.registrations = Map.copyOf(registrations);
|
||||
}
|
||||
|
||||
/** Nothing exposed. The default, and what a deployment that has not decided has. */
|
||||
public static GraphQlRepositoryAllowlist empty() {
|
||||
return new GraphQlRepositoryAllowlist(Map.of());
|
||||
}
|
||||
|
||||
/** A builder, because an allowlist is written once at startup and read on every schema build. */
|
||||
public static Builder builder() {
|
||||
return new Builder();
|
||||
}
|
||||
|
||||
/** Whether a repository is exposed at all, under any coordinate. */
|
||||
public boolean contains(String repositoryName) {
|
||||
return registrations.keySet().stream().anyMatch(key -> key.startsWith(repositoryName + "@"));
|
||||
}
|
||||
|
||||
/** The registration for one exposure, empty when it was never registered. */
|
||||
public Optional<Registration> find(GraphQlRepositoryExposure exposure) {
|
||||
Objects.requireNonNull(exposure, "exposure");
|
||||
return Optional.ofNullable(registrations.get(key(exposure)));
|
||||
}
|
||||
|
||||
/** Every registered exposure, for the startup report. */
|
||||
public List<String> exposures() {
|
||||
return registrations.keySet().stream().sorted().toList();
|
||||
}
|
||||
|
||||
/** How many exposures are registered. */
|
||||
public int size() {
|
||||
return registrations.size();
|
||||
}
|
||||
|
||||
private static String key(GraphQlRepositoryExposure exposure) {
|
||||
return exposure.repositoryName() + "@" + exposure.schemaCoordinate();
|
||||
}
|
||||
|
||||
/**
|
||||
* What one exposure is permitted to do.
|
||||
*
|
||||
* @param exposure which repository at which coordinate
|
||||
* @param arguments the filters and sorts it accepts
|
||||
* @param pagination how it pages
|
||||
* @param projection what it may return
|
||||
*/
|
||||
public record Registration(
|
||||
GraphQlRepositoryExposure exposure,
|
||||
GraphQlRepositoryArgumentPolicy arguments,
|
||||
GraphQlRepositoryPaginationPolicy pagination,
|
||||
GraphQlRepositoryProjectionPolicy projection) {
|
||||
|
||||
public Registration {
|
||||
Objects.requireNonNull(exposure, "exposure");
|
||||
Objects.requireNonNull(arguments, "arguments");
|
||||
Objects.requireNonNull(pagination, "pagination");
|
||||
Objects.requireNonNull(projection, "projection");
|
||||
}
|
||||
}
|
||||
|
||||
/** Collects registrations and refuses a duplicate coordinate. */
|
||||
public static final class Builder {
|
||||
|
||||
private final Map<String, Registration> registrations = new LinkedHashMap<>();
|
||||
|
||||
private Builder() {}
|
||||
|
||||
/** Register one exposure with its three policies. */
|
||||
public Builder expose(
|
||||
GraphQlRepositoryExposure exposure,
|
||||
GraphQlRepositoryArgumentPolicy arguments,
|
||||
GraphQlRepositoryPaginationPolicy pagination,
|
||||
GraphQlRepositoryProjectionPolicy projection) {
|
||||
Registration registration = new Registration(exposure, arguments, pagination, projection);
|
||||
Registration previous = registrations.putIfAbsent(key(exposure), registration);
|
||||
if (previous != null) {
|
||||
throw new IllegalArgumentException(
|
||||
"two registrations for "
|
||||
+ key(exposure)
|
||||
+ "; whichever was added last would silently decide the coordinate's limits");
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Freeze it. */
|
||||
public GraphQlRepositoryAllowlist build() {
|
||||
return new GraphQlRepositoryAllowlist(registrations);
|
||||
}
|
||||
}
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql.advanced.springdata;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Which filter and sort arguments a coordinate accepts.
|
||||
*
|
||||
* <p>The other half of the persistence-leak problem, and the half that is easier to miss. A
|
||||
* query-by-example or Querydsl fetcher derives its predicate from whatever arguments arrive, so an
|
||||
* un-allowlisted argument set means the client writes the {@code WHERE} clause — including over
|
||||
* columns the schema never published, because the binder resolves against the entity rather than
|
||||
* against the GraphQL type.
|
||||
*
|
||||
* <p>Sort is allowlisted separately from filter. They read alike and behave differently under load:
|
||||
* a filter on an unindexed column is one slow query, and a sort on one is a full sort of the table
|
||||
* on every page.
|
||||
*/
|
||||
public final class GraphQlRepositoryArgumentPolicy {
|
||||
|
||||
private final Set<String> filterableFields;
|
||||
private final Set<String> sortableFields;
|
||||
|
||||
/**
|
||||
* @param filterableFields the arguments that may narrow the result
|
||||
* @param sortableFields the fields that may order it
|
||||
*/
|
||||
public GraphQlRepositoryArgumentPolicy(Set<String> filterableFields, Set<String> sortableFields) {
|
||||
this.filterableFields =
|
||||
Set.copyOf(Objects.requireNonNull(filterableFields, "filterableFields"));
|
||||
this.sortableFields = Set.copyOf(Objects.requireNonNull(sortableFields, "sortableFields"));
|
||||
}
|
||||
|
||||
/** A coordinate that accepts no filter and no sort. */
|
||||
public static GraphQlRepositoryArgumentPolicy none() {
|
||||
return new GraphQlRepositoryArgumentPolicy(Set.of(), Set.of());
|
||||
}
|
||||
|
||||
/** Whether a filter argument is permitted. */
|
||||
public boolean filterable(String field) {
|
||||
return field != null && filterableFields.contains(field);
|
||||
}
|
||||
|
||||
/** Whether a sort field is permitted. */
|
||||
public boolean sortable(String field) {
|
||||
return field != null && sortableFields.contains(field);
|
||||
}
|
||||
|
||||
/**
|
||||
* The arguments a request used that this coordinate does not accept.
|
||||
*
|
||||
* <p>All of them, not the first: a caller fixing a query should not discover the rejections one
|
||||
* round trip at a time.
|
||||
*/
|
||||
public java.util.List<String> reject(Set<String> filters, Set<String> sorts) {
|
||||
Objects.requireNonNull(filters, "filters");
|
||||
Objects.requireNonNull(sorts, "sorts");
|
||||
return java.util.stream.Stream.concat(
|
||||
filters.stream().filter(field -> !filterable(field)).map(field -> "filter:" + field),
|
||||
sorts.stream().filter(field -> !sortable(field)).map(field -> "sort:" + field))
|
||||
.sorted()
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql.advanced.springdata;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* One repository offered at one schema coordinate.
|
||||
*
|
||||
* <p>The pair, never the repository alone. Spring's {@code @GraphQlRepository} binds a repository
|
||||
* to whatever coordinate its return type matches, so the same repository reached from {@code
|
||||
* Query.orders} and from {@code Query.allOrders} is two exposures with two different audiences —
|
||||
* and an allowlist keyed only on the repository would authorise both from one decision.
|
||||
*
|
||||
* @param repositoryName the repository bean's name
|
||||
* @param schemaCoordinate the {@code Type.field} it answers
|
||||
*/
|
||||
public record GraphQlRepositoryExposure(String repositoryName, String schemaCoordinate) {
|
||||
|
||||
public GraphQlRepositoryExposure {
|
||||
Objects.requireNonNull(repositoryName, "repositoryName");
|
||||
Objects.requireNonNull(schemaCoordinate, "schemaCoordinate");
|
||||
if (repositoryName.isBlank() || schemaCoordinate.isBlank()) {
|
||||
throw new IllegalArgumentException("an exposure names a repository and a coordinate");
|
||||
}
|
||||
if (schemaCoordinate.indexOf('.') < 1) {
|
||||
throw new IllegalArgumentException("a schema coordinate is Type.field: " + schemaCoordinate);
|
||||
}
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql.advanced.springdata;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* A repository was about to be exposed as a data fetcher without being registered for it.
|
||||
*
|
||||
* <p>Raised at startup rather than at query time. An unregistered exposure that only failed when
|
||||
* somebody queried it would be a schema field that exists, appears in introspection, and errors —
|
||||
* which is worse than one that was never published.
|
||||
*/
|
||||
public final class GraphQlRepositoryExposureRejectedException extends RuntimeException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private final transient String repositoryName;
|
||||
|
||||
public GraphQlRepositoryExposureRejectedException(String repositoryName, String reason) {
|
||||
super("repository exposure refused for '" + repositoryName + "': " + reason);
|
||||
this.repositoryName = Objects.requireNonNull(repositoryName, "repositoryName");
|
||||
}
|
||||
|
||||
/** Which repository. */
|
||||
public String repositoryName() {
|
||||
return repositoryName;
|
||||
}
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql.advanced.springdata;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Refuses a repository exposure the deployment did not register.
|
||||
*
|
||||
* <p>Runs at schema build, not at query time. An unregistered exposure that only failed when
|
||||
* somebody queried it would be a field that exists, appears in introspection and errors — which is
|
||||
* worse than one that was never published, because a client has already written code against it.
|
||||
*
|
||||
* <p>This whole capability is a compatibility path and is documented as one. It exists so an
|
||||
* existing Spring Data GraphQL application can be brought onto this platform without rewriting
|
||||
* every fetcher on day one; it is not the mainstream way to serve a field here, and a coordinate
|
||||
* that stays on it indefinitely is a coordinate whose application-service boundary was never
|
||||
* written.
|
||||
*/
|
||||
public final class GraphQlRepositoryExposureValidator {
|
||||
|
||||
private final GraphQlRepositoryAllowlist allowlist;
|
||||
|
||||
public GraphQlRepositoryExposureValidator(GraphQlRepositoryAllowlist allowlist) {
|
||||
this.allowlist = Objects.requireNonNull(allowlist, "allowlist");
|
||||
}
|
||||
|
||||
/**
|
||||
* Require an exposure to be registered.
|
||||
*
|
||||
* @throws GraphQlRepositoryExposureRejectedException when it is not
|
||||
*/
|
||||
public GraphQlRepositoryAllowlist.Registration verify(GraphQlRepositoryExposure exposure) {
|
||||
Objects.requireNonNull(exposure, "exposure");
|
||||
return allowlist
|
||||
.find(exposure)
|
||||
.orElseThrow(
|
||||
() ->
|
||||
new GraphQlRepositoryExposureRejectedException(
|
||||
exposure.repositoryName(),
|
||||
"no registration for coordinate "
|
||||
+ exposure.schemaCoordinate()
|
||||
+ "; a repository is exposed by a deployment decision, not by an"
|
||||
+ " annotation on a persistence class"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check one request against the coordinate's registered limits.
|
||||
*
|
||||
* @param exposure which repository at which coordinate
|
||||
* @param filters the filter arguments the request used
|
||||
* @param sorts the sort fields it used
|
||||
* @param pageSize the page size it asked for
|
||||
* @param returnTypeName the type the fetcher would return
|
||||
* @throws GraphQlRepositoryExposureRejectedException naming every violation at once
|
||||
*/
|
||||
public void verifyRequest(
|
||||
GraphQlRepositoryExposure exposure,
|
||||
Set<String> filters,
|
||||
Set<String> sorts,
|
||||
int pageSize,
|
||||
String returnTypeName) {
|
||||
GraphQlRepositoryAllowlist.Registration registration = verify(exposure);
|
||||
List<String> refused =
|
||||
new java.util.ArrayList<>(registration.arguments().reject(filters, sorts));
|
||||
if (!registration.pagination().permits(pageSize)) {
|
||||
refused.add("page size " + pageSize + " exceeds " + registration.pagination().maxPageSize());
|
||||
}
|
||||
if (!registration.projection().permits(returnTypeName)) {
|
||||
// The one that keeps this from becoming a database export. Returning the entity publishes
|
||||
// every column and makes a rename in the database a breaking change for every client.
|
||||
refused.add(
|
||||
"return type "
|
||||
+ returnTypeName
|
||||
+ " is not an approved projection; returning the entity publishes the persistence"
|
||||
+ " model as an API");
|
||||
}
|
||||
if (!refused.isEmpty()) {
|
||||
throw new GraphQlRepositoryExposureRejectedException(
|
||||
exposure.repositoryName(), String.join("; ", refused));
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the deployment has exposed anything at all. */
|
||||
public boolean anyExposure() {
|
||||
return allowlist.size() > 0;
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql.advanced.springdata;
|
||||
|
||||
/**
|
||||
* How many rows one coordinate may return, and how they are addressed.
|
||||
*
|
||||
* <p>Explicit, never inherited. Spring Data's GraphQL integration defaults to offset pagination
|
||||
* with a page size of twenty, and a default is exactly what nobody reviews: a field that quietly
|
||||
* serves twenty rows is fine until the client asks for the offset at row four hundred thousand, at
|
||||
* which point the database is sorting the whole table to skip it.
|
||||
*
|
||||
* <p>Keyset is the recommended shape and the policy says which one is in use, because the two have
|
||||
* different failure modes and an operator reading a slow query needs to know which they are looking
|
||||
* at.
|
||||
*
|
||||
* @param keyset whether the coordinate pages by cursor rather than by offset
|
||||
* @param maxPageSize the largest page a caller may request
|
||||
*/
|
||||
public record GraphQlRepositoryPaginationPolicy(boolean keyset, int maxPageSize) {
|
||||
|
||||
public GraphQlRepositoryPaginationPolicy {
|
||||
if (maxPageSize < 1) {
|
||||
throw new IllegalArgumentException("a page of nothing is not a page");
|
||||
}
|
||||
if (maxPageSize > 200) {
|
||||
throw new IllegalArgumentException(
|
||||
"a page above 200 rows makes one query a bulk export; if that is the intent it belongs"
|
||||
+ " on an endpoint that was designed for it");
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a requested page size is permitted. */
|
||||
public boolean permits(int requested) {
|
||||
return requested >= 1 && requested <= maxPageSize;
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql.advanced.springdata;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Which projection types a coordinate may return.
|
||||
*
|
||||
* <p>The rule that keeps this compatibility path from becoming a persistence-model export. Spring
|
||||
* Data's integration will happily return the entity, and an entity returned from GraphQL is the
|
||||
* database schema published as an API — every column, every relation the fetch plan happens to
|
||||
* traverse, and a rename in the database becoming a breaking change for every client.
|
||||
*
|
||||
* <p>So the entity type itself is refused by name, and only an approved projection is allowed.
|
||||
*/
|
||||
public final class GraphQlRepositoryProjectionPolicy {
|
||||
|
||||
private final Set<String> approvedProjections;
|
||||
private final Set<String> forbiddenEntityTypes;
|
||||
|
||||
/**
|
||||
* @param approvedProjections the projection interfaces or records that may be returned
|
||||
* @param forbiddenEntityTypes the persistence types that may never be
|
||||
*/
|
||||
public GraphQlRepositoryProjectionPolicy(
|
||||
Set<String> approvedProjections, Set<String> forbiddenEntityTypes) {
|
||||
this.approvedProjections =
|
||||
Set.copyOf(Objects.requireNonNull(approvedProjections, "approvedProjections"));
|
||||
this.forbiddenEntityTypes =
|
||||
Set.copyOf(Objects.requireNonNull(forbiddenEntityTypes, "forbiddenEntityTypes"));
|
||||
if (this.approvedProjections.isEmpty()) {
|
||||
throw new IllegalArgumentException(
|
||||
"a coordinate with no approved projection can only return the entity, which is the one"
|
||||
+ " thing this policy exists to prevent");
|
||||
}
|
||||
Set<String> both =
|
||||
this.approvedProjections.stream()
|
||||
.filter(this.forbiddenEntityTypes::contains)
|
||||
.collect(java.util.stream.Collectors.toUnmodifiableSet());
|
||||
if (!both.isEmpty()) {
|
||||
throw new IllegalArgumentException(
|
||||
"a type cannot be both an approved projection and a forbidden entity: " + both);
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a return type may be served. */
|
||||
public boolean permits(String returnTypeName) {
|
||||
return returnTypeName != null
|
||||
&& !forbiddenEntityTypes.contains(returnTypeName)
|
||||
&& approvedProjections.contains(returnTypeName);
|
||||
}
|
||||
|
||||
/** The approved projections, for the startup report. */
|
||||
public Set<String> approvedProjections() {
|
||||
return approvedProjections;
|
||||
}
|
||||
}
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql.advanced.sse;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap.GraphQlAdvancedCapability;
|
||||
import dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap.GraphQlAdvancedCapabilityDisabledException;
|
||||
import dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap.GraphQlAdvancedFeatureFlags;
|
||||
import java.time.Duration;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Decides whether the SSE subscription transport may exist, and on what terms.
|
||||
*
|
||||
* <p>SSE here is the Distinct Connection variant of the GraphQL-over-HTTP specification: one POST
|
||||
* per subscription, each holding its own connection for as long as the subscription lives. That is
|
||||
* the property worth stating at the factory, because it is the one that surprises people — {@code
|
||||
* graphql-transport-ws} multiplexes many subscriptions onto one socket and this does not, so a
|
||||
* client with twenty subscriptions holds twenty connections and a browser's six-per-origin limit is
|
||||
* reached at six.
|
||||
*
|
||||
* <p>{@link GraphQlSseConnectionPolicy#connectionsRequiredFor(int)} is the arithmetic; this is
|
||||
* where the transport is refused if the capability was not named.
|
||||
*/
|
||||
public final class GraphQlSseHandlerFactory {
|
||||
|
||||
private final GraphQlAdvancedFeatureFlags flags;
|
||||
private final GraphQlSseProperties properties;
|
||||
private final GraphQlSseConnectionPolicy policy;
|
||||
|
||||
/**
|
||||
* @param flags which capabilities this deployment named
|
||||
* @param properties the connection bounds
|
||||
* @param policy what the transport may carry
|
||||
*/
|
||||
public GraphQlSseHandlerFactory(
|
||||
GraphQlAdvancedFeatureFlags flags,
|
||||
GraphQlSseProperties properties,
|
||||
GraphQlSseConnectionPolicy policy) {
|
||||
this.flags = Objects.requireNonNull(flags, "flags");
|
||||
this.properties = Objects.requireNonNull(properties, "properties");
|
||||
this.policy = Objects.requireNonNull(policy, "policy");
|
||||
}
|
||||
|
||||
/** Whether the transport may be installed at all. */
|
||||
public boolean enabled() {
|
||||
return flags.isEnabled(GraphQlAdvancedCapability.SSE_SUBSCRIPTION);
|
||||
}
|
||||
|
||||
/**
|
||||
* The settings a handler is installed with.
|
||||
*
|
||||
* @throws GraphQlAdvancedCapabilityDisabledException when the capability was not named
|
||||
*/
|
||||
public GraphQlSseHandlerSettings settings() {
|
||||
if (!enabled()) {
|
||||
throw new GraphQlAdvancedCapabilityDisabledException(
|
||||
GraphQlAdvancedCapability.SSE_SUBSCRIPTION.name());
|
||||
}
|
||||
return new GraphQlSseHandlerSettings(
|
||||
properties.heartbeatInterval(),
|
||||
properties.idleTimeout(),
|
||||
properties.maximumDuration(),
|
||||
policy);
|
||||
}
|
||||
|
||||
/**
|
||||
* How many connections a client running this many subscriptions will hold.
|
||||
*
|
||||
* <p>Exposed on the factory so a deployment can answer the question before enabling the transport
|
||||
* rather than after a support ticket about a browser that stopped loading images.
|
||||
*/
|
||||
public int connectionsRequiredFor(int subscriptions) {
|
||||
return policy.connectionsRequiredFor(subscriptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* What a handler is configured with.
|
||||
*
|
||||
* @param heartbeatInterval how often a keepalive is written
|
||||
* @param idleTimeout how long a silent connection is held
|
||||
* @param maximumDuration the ceiling regardless of activity
|
||||
* @param policy what the transport may carry
|
||||
*/
|
||||
public record GraphQlSseHandlerSettings(
|
||||
Duration heartbeatInterval,
|
||||
Duration idleTimeout,
|
||||
Duration maximumDuration,
|
||||
GraphQlSseConnectionPolicy policy) {
|
||||
|
||||
public GraphQlSseHandlerSettings {
|
||||
Objects.requireNonNull(heartbeatInterval, "heartbeatInterval");
|
||||
Objects.requireNonNull(idleTimeout, "idleTimeout");
|
||||
Objects.requireNonNull(maximumDuration, "maximumDuration");
|
||||
Objects.requireNonNull(policy, "policy");
|
||||
}
|
||||
}
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql.advanced.websocket;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap.GraphQlAdvancedCapability;
|
||||
import dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap.GraphQlAdvancedCapabilityDisabledException;
|
||||
import dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap.GraphQlAdvancedFeatureFlags;
|
||||
import java.time.Duration;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Decides whether the {@code graphql-transport-ws} handler may exist, and on what terms.
|
||||
*
|
||||
* <p>A factory rather than a bean definition because the decision has three inputs and only one of
|
||||
* them is a flag: the capability must be enabled, the properties must describe a connection that
|
||||
* can actually be bounded, and the admission policy must be present. A configuration class that
|
||||
* checked only the flag would produce a handler with an unbounded connection lifetime whenever a
|
||||
* deployment forgot the rest, and an unbounded WebSocket is a connection slot held by whoever opens
|
||||
* it.
|
||||
*
|
||||
* <p>The factory produces the settings a handler needs rather than the handler itself. Spring
|
||||
* GraphQL's own {@code GraphQlWebSocketHandler} is the handler; what this leaf owns is whether it
|
||||
* runs and what limits it runs under, and building the Spring type here would put a framework
|
||||
* dependency in a module whose whole job is the decision.
|
||||
*/
|
||||
public final class GraphQlWebSocketHandlerFactory {
|
||||
|
||||
private final GraphQlAdvancedFeatureFlags flags;
|
||||
private final GraphQlWebSocketProperties properties;
|
||||
private final GraphQlWebSocketAdmission admission;
|
||||
|
||||
/**
|
||||
* @param flags which capabilities this deployment named
|
||||
* @param properties the connection bounds
|
||||
* @param admission who may open a connection
|
||||
*/
|
||||
public GraphQlWebSocketHandlerFactory(
|
||||
GraphQlAdvancedFeatureFlags flags,
|
||||
GraphQlWebSocketProperties properties,
|
||||
GraphQlWebSocketAdmission admission) {
|
||||
this.flags = Objects.requireNonNull(flags, "flags");
|
||||
this.properties = Objects.requireNonNull(properties, "properties");
|
||||
this.admission = Objects.requireNonNull(admission, "admission");
|
||||
}
|
||||
|
||||
/** Whether the transport may be installed at all. */
|
||||
public boolean enabled() {
|
||||
return flags.isEnabled(GraphQlAdvancedCapability.WEBSOCKET_SUBSCRIPTION);
|
||||
}
|
||||
|
||||
/**
|
||||
* The settings a handler is installed with.
|
||||
*
|
||||
* @throws GraphQlAdvancedCapabilityDisabledException when the capability was not named
|
||||
*/
|
||||
public GraphQlWebSocketHandlerSettings settings() {
|
||||
if (!enabled()) {
|
||||
// Refused rather than returning a disabled handler. A handler that exists and rejects every
|
||||
// frame is an endpoint that answers, which is how a client discovers the transport is
|
||||
// "supported" and writes code against it.
|
||||
throw new GraphQlAdvancedCapabilityDisabledException(
|
||||
GraphQlAdvancedCapability.WEBSOCKET_SUBSCRIPTION.name());
|
||||
}
|
||||
return new GraphQlWebSocketHandlerSettings(
|
||||
properties.connectionInitTimeout(),
|
||||
properties.idleTimeout(),
|
||||
properties.maximumConnectionAge(),
|
||||
properties.maximumSubscriptionsPerConnection(),
|
||||
properties.heartbeatInterval(),
|
||||
admission);
|
||||
}
|
||||
|
||||
/**
|
||||
* What a handler is configured with.
|
||||
*
|
||||
* @param connectionInitTimeout how long an unacknowledged connection is held
|
||||
* @param idleTimeout how long a silent connection is held
|
||||
* @param maximumConnectionAge the ceiling regardless of activity
|
||||
* @param maximumSubscriptionsPerConnection how many operations one socket may run
|
||||
* @param heartbeatInterval how often a keepalive is written
|
||||
* @param admission who may open a connection
|
||||
*/
|
||||
public record GraphQlWebSocketHandlerSettings(
|
||||
Duration connectionInitTimeout,
|
||||
Duration idleTimeout,
|
||||
Duration maximumConnectionAge,
|
||||
int maximumSubscriptionsPerConnection,
|
||||
Duration heartbeatInterval,
|
||||
GraphQlWebSocketAdmission admission) {
|
||||
|
||||
public GraphQlWebSocketHandlerSettings {
|
||||
Objects.requireNonNull(connectionInitTimeout, "connectionInitTimeout");
|
||||
Objects.requireNonNull(idleTimeout, "idleTimeout");
|
||||
Objects.requireNonNull(maximumConnectionAge, "maximumConnectionAge");
|
||||
Objects.requireNonNull(heartbeatInterval, "heartbeatInterval");
|
||||
Objects.requireNonNull(admission, "admission");
|
||||
if (heartbeatInterval.compareTo(idleTimeout) >= 0) {
|
||||
throw new IllegalArgumentException(
|
||||
"a heartbeat at or beyond the idle timeout makes the server time out its own healthy"
|
||||
+ " connections between beats");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+65
-33
@@ -67,8 +67,8 @@ public final class GraphQlSchemaComparator {
|
||||
// an extension contributed — and cannot see it disappear either. Every schema that composes
|
||||
// from several files is exactly this shape, which made the omission a breaking change the gate
|
||||
// reported as no change at all.
|
||||
Map<String, TypeDefinition> previousTypes = withExtensions(previous);
|
||||
Map<String, TypeDefinition> candidateTypes = withExtensions(candidate);
|
||||
Map<String, TypeDefinition<?>> previousTypes = withExtensions(previous);
|
||||
Map<String, TypeDefinition<?>> candidateTypes = withExtensions(candidate);
|
||||
|
||||
compareTypePresence(previousTypes, candidateTypes, changes);
|
||||
compareTypeKinds(previousTypes, candidateTypes, changes);
|
||||
@@ -89,14 +89,16 @@ public final class GraphQlSchemaComparator {
|
||||
* <p>The merged form is what a client sees: the engine builds one type out of the base
|
||||
* declaration and every extension of it, and a field's origin is invisible on the wire.
|
||||
*/
|
||||
private static Map<String, TypeDefinition> withExtensions(TypeDefinitionRegistry registry) {
|
||||
Map<String, TypeDefinition> merged = new LinkedHashMap<>();
|
||||
private static Map<String, TypeDefinition<?>> withExtensions(TypeDefinitionRegistry registry) {
|
||||
Map<String, TypeDefinition<?>> merged = new LinkedHashMap<>();
|
||||
registry.types().forEach((name, type) -> merged.put(name, mergeExtensions(registry, type)));
|
||||
return merged;
|
||||
}
|
||||
|
||||
private static TypeDefinition mergeExtensions(
|
||||
TypeDefinitionRegistry registry, TypeDefinition type) {
|
||||
// graphql.language.Type stays raw here; see the note on typeNames.
|
||||
@SuppressWarnings("rawtypes")
|
||||
private static TypeDefinition<?> mergeExtensions(
|
||||
TypeDefinitionRegistry registry, TypeDefinition<?> type) {
|
||||
|
||||
String name = type.getName();
|
||||
if (type instanceof ObjectTypeDefinition object) {
|
||||
@@ -187,8 +189,8 @@ public final class GraphQlSchemaComparator {
|
||||
}
|
||||
|
||||
/** The merged definitions of one kind. */
|
||||
private static <T extends TypeDefinition> Map<String, T> typesOf(
|
||||
Map<String, TypeDefinition> types, Class<T> kind) {
|
||||
private static <T extends TypeDefinition<?>> Map<String, T> typesOf(
|
||||
Map<String, TypeDefinition<?>> types, Class<T> kind) {
|
||||
Map<String, T> selected = new LinkedHashMap<>();
|
||||
types.forEach(
|
||||
(name, type) -> {
|
||||
@@ -200,8 +202,8 @@ public final class GraphQlSchemaComparator {
|
||||
}
|
||||
|
||||
private static void compareTypePresence(
|
||||
Map<String, TypeDefinition> previous,
|
||||
Map<String, TypeDefinition> candidate,
|
||||
Map<String, TypeDefinition<?>> previous,
|
||||
Map<String, TypeDefinition<?>> candidate,
|
||||
List<GraphQlSchemaChange> changes) {
|
||||
|
||||
Set<String> previousTypes = new TreeSet<>(previous.keySet());
|
||||
@@ -223,12 +225,12 @@ public final class GraphQlSchemaComparator {
|
||||
* to another, or as nothing at all.
|
||||
*/
|
||||
private static void compareTypeKinds(
|
||||
Map<String, TypeDefinition> previousTypes,
|
||||
Map<String, TypeDefinition> candidateTypes,
|
||||
Map<String, TypeDefinition<?>> previousTypes,
|
||||
Map<String, TypeDefinition<?>> candidateTypes,
|
||||
List<GraphQlSchemaChange> changes) {
|
||||
|
||||
for (String name : new TreeSet<>(previousTypes.keySet())) {
|
||||
TypeDefinition after = candidateTypes.get(name);
|
||||
TypeDefinition<?> after = candidateTypes.get(name);
|
||||
if (after == null) {
|
||||
continue;
|
||||
}
|
||||
@@ -246,13 +248,13 @@ public final class GraphQlSchemaComparator {
|
||||
* definition untouched.
|
||||
*/
|
||||
private static void compareAppliedDirectives(
|
||||
Map<String, TypeDefinition> previousTypes,
|
||||
Map<String, TypeDefinition> candidateTypes,
|
||||
Map<String, TypeDefinition<?>> previousTypes,
|
||||
Map<String, TypeDefinition<?>> candidateTypes,
|
||||
List<GraphQlSchemaChange> changes) {
|
||||
|
||||
for (String name : new TreeSet<>(previousTypes.keySet())) {
|
||||
TypeDefinition before = previousTypes.get(name);
|
||||
TypeDefinition after = candidateTypes.get(name);
|
||||
TypeDefinition<?> before = previousTypes.get(name);
|
||||
TypeDefinition<?> after = candidateTypes.get(name);
|
||||
if (after == null || !before.getClass().equals(after.getClass())) {
|
||||
continue;
|
||||
}
|
||||
@@ -327,8 +329,8 @@ public final class GraphQlSchemaComparator {
|
||||
}
|
||||
|
||||
private static void compareOutputTypes(
|
||||
Map<String, TypeDefinition> previous,
|
||||
Map<String, TypeDefinition> candidate,
|
||||
Map<String, TypeDefinition<?>> previous,
|
||||
Map<String, TypeDefinition<?>> candidate,
|
||||
List<GraphQlSchemaChange> changes) {
|
||||
|
||||
Map<String, ImplementingTypeDefinition<?>> previousTypes = implementingTypes(previous);
|
||||
@@ -382,8 +384,10 @@ public final class GraphQlSchemaComparator {
|
||||
: GraphQlChangeKind.OUTPUT_FIELD_ADDED_NULLABLE)));
|
||||
}
|
||||
|
||||
// graphql.language.Type stays raw here; see the note on typeNames.
|
||||
@SuppressWarnings("rawtypes")
|
||||
private static void compareOutputFieldType(
|
||||
String coordinate, Type<?> before, Type<?> after, List<GraphQlSchemaChange> changes) {
|
||||
String coordinate, Type before, Type after, List<GraphQlSchemaChange> changes) {
|
||||
|
||||
if (sameType(before, after)) {
|
||||
return;
|
||||
@@ -474,8 +478,8 @@ public final class GraphQlSchemaComparator {
|
||||
}
|
||||
|
||||
private static void compareInputTypes(
|
||||
Map<String, TypeDefinition> previous,
|
||||
Map<String, TypeDefinition> candidate,
|
||||
Map<String, TypeDefinition<?>> previous,
|
||||
Map<String, TypeDefinition<?>> candidate,
|
||||
List<GraphQlSchemaChange> changes) {
|
||||
|
||||
Map<String, InputObjectTypeDefinition> previousTypes =
|
||||
@@ -527,10 +531,12 @@ public final class GraphQlSchemaComparator {
|
||||
}
|
||||
}
|
||||
|
||||
// graphql.language.Type stays raw here; see the note on typeNames.
|
||||
@SuppressWarnings("rawtypes")
|
||||
private static void compareInputValueType(
|
||||
String coordinate,
|
||||
Type<?> before,
|
||||
Type<?> after,
|
||||
Type before,
|
||||
Type after,
|
||||
GraphQlChangeKind strengthened,
|
||||
GraphQlChangeKind relaxed,
|
||||
GraphQlChangeKind changed,
|
||||
@@ -551,8 +557,8 @@ public final class GraphQlSchemaComparator {
|
||||
}
|
||||
|
||||
private static void compareEnums(
|
||||
Map<String, TypeDefinition> previous,
|
||||
Map<String, TypeDefinition> candidate,
|
||||
Map<String, TypeDefinition<?>> previous,
|
||||
Map<String, TypeDefinition<?>> candidate,
|
||||
List<GraphQlSchemaChange> changes) {
|
||||
|
||||
Map<String, EnumTypeDefinition> previousTypes = typesOf(previous, EnumTypeDefinition.class);
|
||||
@@ -586,8 +592,8 @@ public final class GraphQlSchemaComparator {
|
||||
}
|
||||
|
||||
private static void compareUnions(
|
||||
Map<String, TypeDefinition> previous,
|
||||
Map<String, TypeDefinition> candidate,
|
||||
Map<String, TypeDefinition<?>> previous,
|
||||
Map<String, TypeDefinition<?>> candidate,
|
||||
List<GraphQlSchemaChange> changes) {
|
||||
|
||||
Map<String, UnionTypeDefinition> previousTypes = typesOf(previous, UnionTypeDefinition.class);
|
||||
@@ -677,7 +683,7 @@ public final class GraphQlSchemaComparator {
|
||||
}
|
||||
|
||||
private static Map<String, ImplementingTypeDefinition<?>> implementingTypes(
|
||||
Map<String, TypeDefinition> merged) {
|
||||
Map<String, TypeDefinition<?>> merged) {
|
||||
Map<String, ImplementingTypeDefinition<?>> types = new LinkedHashMap<>();
|
||||
typesOf(merged, ObjectTypeDefinition.class).forEach(types::put);
|
||||
typesOf(merged, InterfaceTypeDefinition.class).forEach(types::put);
|
||||
@@ -720,6 +726,26 @@ public final class GraphQlSchemaComparator {
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
}
|
||||
|
||||
/**
|
||||
* The names of a type reference list, taken exactly as graphql-java hands it over.
|
||||
*
|
||||
* <p>Every {@code TypeDefinition} in this class is parameterized. {@code graphql.language.Type}
|
||||
* is not, and cannot be: graphql-java declares {@code Type<T extends Type>} — its own bound is
|
||||
* raw — and its collections are raw on both sides. {@code getImplements()} and {@code
|
||||
* getMemberTypes()} return {@code List<Type>}, which is not assignable to {@code List<? extends
|
||||
* Type<?>>} because a raw element type is not a subtype of {@code Type<?>} in an argument
|
||||
* position; and {@code UnionTypeDefinition.Builder.memberTypes} takes {@code List<Type>} back, so
|
||||
* a parameterized local cannot be handed to it either. Reading and writing are both raw.
|
||||
*
|
||||
* <p>So the rawness is admitted at the six members that touch {@code Type} rather than hidden
|
||||
* behind defensive copies at every boundary. A copy would imply this class needed one, and the
|
||||
* suppression is deliberately per-member rather than on the class so that a raw type introduced
|
||||
* anywhere else here still surfaces.
|
||||
*
|
||||
* <p>The build does not enable {@code -Xlint:rawtypes}, so javac is silent either way; this
|
||||
* exists for the editor's compiler, which is not silent and should not be made silent globally.
|
||||
*/
|
||||
@SuppressWarnings("rawtypes")
|
||||
private static Set<String> typeNames(List<Type> types) {
|
||||
return types.stream()
|
||||
.map(GraphQlSchemaComparator::print)
|
||||
@@ -730,15 +756,21 @@ public final class GraphQlSchemaComparator {
|
||||
return nonNull(definition.getType()) && definition.getDefaultValue() == null;
|
||||
}
|
||||
|
||||
private static boolean nonNull(Type<?> type) {
|
||||
// graphql.language.Type stays raw here; see the note on typeNames.
|
||||
@SuppressWarnings("rawtypes")
|
||||
private static boolean nonNull(Type type) {
|
||||
return type instanceof NonNullType;
|
||||
}
|
||||
|
||||
private static Type<?> unwrap(Type<?> type) {
|
||||
// graphql.language.Type stays raw here; see the note on typeNames.
|
||||
@SuppressWarnings("rawtypes")
|
||||
private static Type unwrap(Type type) {
|
||||
return type instanceof NonNullType nonNullType ? nonNullType.getType() : type;
|
||||
}
|
||||
|
||||
private static boolean sameType(Type<?> left, Type<?> right) {
|
||||
// graphql.language.Type stays raw here; see the note on typeNames.
|
||||
@SuppressWarnings("rawtypes")
|
||||
private static boolean sameType(Type left, Type right) {
|
||||
return print(left).equals(print(right));
|
||||
}
|
||||
|
||||
@@ -747,7 +779,7 @@ public final class GraphQlSchemaComparator {
|
||||
}
|
||||
|
||||
/** Names every definition kind the comparator understands, for coverage reporting. */
|
||||
public static Set<Class<? extends TypeDefinition>> comparedDefinitionKinds() {
|
||||
public static Set<Class<? extends TypeDefinition<?>>> comparedDefinitionKinds() {
|
||||
return Set.of(
|
||||
ObjectTypeDefinition.class,
|
||||
InterfaceTypeDefinition.class,
|
||||
|
||||
+10
@@ -88,6 +88,16 @@ public enum GraphQlAdvancedModule {
|
||||
"security"),
|
||||
|
||||
/** The GraphQL over WebSocket protocol state machine. */
|
||||
/**
|
||||
* The allowlisted Spring Data compatibility path.
|
||||
*
|
||||
* <p>Pure policy and no edge to persistence, which is the point: this module decides which
|
||||
* repository exposures a deployment published, and it decides that without being able to name a
|
||||
* repository, an entity or a Spring Data type. Everything it works with is a name the deployment
|
||||
* registered.
|
||||
*/
|
||||
SPRING_DATA("advanced.springdata", "advanced.springdata"),
|
||||
|
||||
WEBSOCKET("advanced.websocket", "advanced.websocket", "advanced.bootstrap");
|
||||
|
||||
private final String id;
|
||||
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql.advanced;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap.GraphQlAdvancedCapability;
|
||||
import dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap.GraphQlAdvancedCapabilityDisabledException;
|
||||
import dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap.GraphQlAdvancedFeatureFlags;
|
||||
import dev.caskeleton.adapter.inbound.graphql.advanced.bootstrap.GraphQlAdvancedModuleGuard;
|
||||
import dev.caskeleton.adapter.inbound.graphql.advanced.rsocket.GraphQlRSocketHandlerFactory;
|
||||
import dev.caskeleton.adapter.inbound.graphql.advanced.rsocket.GraphQlRSocketProperties;
|
||||
import dev.caskeleton.adapter.inbound.graphql.advanced.rsocket.GraphQlRSocketRoutePolicy;
|
||||
import dev.caskeleton.adapter.inbound.graphql.advanced.sse.GraphQlSseConnectionPolicy;
|
||||
import dev.caskeleton.adapter.inbound.graphql.advanced.sse.GraphQlSseHandlerFactory;
|
||||
import dev.caskeleton.adapter.inbound.graphql.advanced.sse.GraphQlSseProperties;
|
||||
import dev.caskeleton.adapter.inbound.graphql.advanced.websocket.GraphQlWebSocketAdmission;
|
||||
import dev.caskeleton.adapter.inbound.graphql.advanced.websocket.GraphQlWebSocketHandlerFactory;
|
||||
import dev.caskeleton.adapter.inbound.graphql.advanced.websocket.GraphQlWebSocketProperties;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.Set;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* Whether each transport may exist at all, and what it is installed with.
|
||||
*
|
||||
* <p>The three factories are the seam between "a deployment named this capability" and "a handler
|
||||
* is serving requests". The seam matters because the alternative — a handler that is always
|
||||
* installed and rejects when disabled — is an endpoint that answers, and an endpoint that answers
|
||||
* is one a client writes code against.
|
||||
*/
|
||||
class TransportHandlerFactoryTest {
|
||||
|
||||
private static final Clock CLOCK =
|
||||
Clock.fixed(java.time.Instant.parse("2026-08-25T10:00:00Z"), ZoneOffset.UTC);
|
||||
|
||||
private static GraphQlWebSocketAdmission admission(GraphQlAdvancedFeatureFlags flags) {
|
||||
return new GraphQlWebSocketAdmission(
|
||||
new GraphQlAdvancedModuleGuard(flags), GraphQlWebSocketProperties.defaults(), CLOCK);
|
||||
}
|
||||
|
||||
private static GraphQlWebSocketHandlerFactory websocket(GraphQlAdvancedFeatureFlags flags) {
|
||||
return new GraphQlWebSocketHandlerFactory(
|
||||
flags, GraphQlWebSocketProperties.defaults(), admission(flags));
|
||||
}
|
||||
|
||||
private static GraphQlSseHandlerFactory sse(GraphQlAdvancedFeatureFlags flags) {
|
||||
return new GraphQlSseHandlerFactory(
|
||||
flags, GraphQlSseProperties.defaults(), GraphQlSseConnectionPolicy.standard());
|
||||
}
|
||||
|
||||
private static GraphQlRSocketHandlerFactory rsocket(
|
||||
GraphQlAdvancedFeatureFlags flags, boolean transportEnabled) {
|
||||
return new GraphQlRSocketHandlerFactory(
|
||||
flags,
|
||||
new GraphQlRSocketProperties(
|
||||
transportEnabled,
|
||||
Set.of("graphql"),
|
||||
Set.of("message/x.rsocket.routing.v0"),
|
||||
transportEnabled ? Set.of("internal-gateway") : Set.of()),
|
||||
new GraphQlRSocketRoutePolicy(Set.of("graphql")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("no transport exists until its capability is named")
|
||||
void noTransportExistsUntilNamed() {
|
||||
GraphQlAdvancedFeatureFlags off = GraphQlAdvancedFeatureFlags.disabled();
|
||||
|
||||
assertThat(websocket(off).enabled()).isFalse();
|
||||
assertThat(sse(off).enabled()).isFalse();
|
||||
assertThat(rsocket(off, false).enabled()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("asking a disabled transport for its settings is refused, not answered emptily")
|
||||
void disabledTransportRefusesRatherThanAnswering() {
|
||||
// A handler that exists and rejects every request is an endpoint that answers, which is how a
|
||||
// client discovers the transport is "supported".
|
||||
GraphQlAdvancedFeatureFlags off = GraphQlAdvancedFeatureFlags.disabled();
|
||||
|
||||
assertThatThrownBy(() -> websocket(off).settings())
|
||||
.isInstanceOf(GraphQlAdvancedCapabilityDisabledException.class);
|
||||
assertThatThrownBy(() -> sse(off).settings())
|
||||
.isInstanceOf(GraphQlAdvancedCapabilityDisabledException.class);
|
||||
assertThatThrownBy(() -> rsocket(off, false).settings())
|
||||
.isInstanceOf(GraphQlAdvancedCapabilityDisabledException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a named WebSocket capability produces bounded settings")
|
||||
void websocketSettingsAreBounded() {
|
||||
GraphQlWebSocketHandlerFactory.GraphQlWebSocketHandlerSettings settings =
|
||||
websocket(
|
||||
GraphQlAdvancedFeatureFlags.enabling(
|
||||
GraphQlAdvancedCapability.WEBSOCKET_SUBSCRIPTION))
|
||||
.settings();
|
||||
|
||||
assertThat(settings.connectionInitTimeout()).isPositive();
|
||||
assertThat(settings.maximumConnectionAge()).isPositive();
|
||||
assertThat(settings.maximumSubscriptionsPerConnection()).isPositive();
|
||||
assertThat(settings.heartbeatInterval()).isLessThan(settings.idleTimeout());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a heartbeat at or beyond the idle timeout is refused")
|
||||
void selfDefeatingHeartbeatIsRefused() {
|
||||
// The server would time out its own healthy connections between beats.
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new GraphQlWebSocketHandlerFactory.GraphQlWebSocketHandlerSettings(
|
||||
Duration.ofSeconds(10),
|
||||
Duration.ofSeconds(30),
|
||||
Duration.ofHours(1),
|
||||
10,
|
||||
Duration.ofSeconds(30),
|
||||
admission(GraphQlAdvancedFeatureFlags.disabled())))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("time out its own healthy connections");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("SSE says how many connections a client will hold before it is enabled")
|
||||
void sseReportsItsConnectionCost() {
|
||||
// Distinct Connection: one POST per subscription. A client with twenty subscriptions holds
|
||||
// twenty connections, and a browser's six-per-origin limit is reached at six.
|
||||
GraphQlSseHandlerFactory factory =
|
||||
sse(GraphQlAdvancedFeatureFlags.enabling(GraphQlAdvancedCapability.SSE_SUBSCRIPTION));
|
||||
|
||||
assertThat(factory.connectionsRequiredFor(20)).isEqualTo(20);
|
||||
assertThat(factory.settings().heartbeatInterval()).isLessThan(factory.settings().idleTimeout());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("RSocket needs the transport switch as well as the capability")
|
||||
void rsocketNeedsBothGates() {
|
||||
// Two acts rather than one. A flag is set by whoever edits configuration; enabling an
|
||||
// experimental transport should not be reachable the same way a supported one is.
|
||||
GraphQlAdvancedFeatureFlags named =
|
||||
GraphQlAdvancedFeatureFlags.enabling(GraphQlAdvancedCapability.RSOCKET);
|
||||
|
||||
assertThat(rsocket(named, false).enabled()).isFalse();
|
||||
assertThat(rsocket(named, true).enabled()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an experimental transport with no named consumer is refused")
|
||||
void unnamedConsumerIsRefused() {
|
||||
// One nobody will notice breaking, which is the state it must not reach.
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
new GraphQlRSocketHandlerFactory.GraphQlRSocketHandlerSettings(
|
||||
new GraphQlRSocketRoutePolicy(Set.of("graphql")), Set.of(), Set.of()))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("nobody will notice breaking");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("enabling one transport does not enable another")
|
||||
void capabilitiesAreIndependent() {
|
||||
GraphQlAdvancedFeatureFlags onlyWebSocket =
|
||||
GraphQlAdvancedFeatureFlags.enabling(GraphQlAdvancedCapability.WEBSOCKET_SUBSCRIPTION);
|
||||
|
||||
assertThat(websocket(onlyWebSocket).enabled()).isTrue();
|
||||
assertThat(sse(onlyWebSocket).enabled()).isFalse();
|
||||
assertThat(rsocket(onlyWebSocket, true).enabled()).isFalse();
|
||||
}
|
||||
}
|
||||
+214
@@ -0,0 +1,214 @@
|
||||
package dev.caskeleton.adapter.inbound.graphql.advanced.springdata;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import java.util.Set;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* The allowlist that stops a persistence annotation from publishing an API.
|
||||
*
|
||||
* <p>Spring's {@code @GraphQlRepository} registers a data fetcher for every annotated repository it
|
||||
* finds. Without this, the set of exposed fields is whatever somebody annotated — a decision made
|
||||
* in a persistence class, by whoever was working on persistence, and reviewed as a persistence
|
||||
* change.
|
||||
*/
|
||||
class GraphQlRepositoryExposureValidatorTest {
|
||||
|
||||
private static final GraphQlRepositoryExposure ORDERS =
|
||||
new GraphQlRepositoryExposure("OrderRepository", "Query.orders");
|
||||
|
||||
private static GraphQlRepositoryAllowlist allowlistWith(
|
||||
GraphQlRepositoryArgumentPolicy arguments,
|
||||
GraphQlRepositoryPaginationPolicy pagination,
|
||||
GraphQlRepositoryProjectionPolicy projection) {
|
||||
return GraphQlRepositoryAllowlist.builder()
|
||||
.expose(ORDERS, arguments, pagination, projection)
|
||||
.build();
|
||||
}
|
||||
|
||||
private static GraphQlRepositoryProjectionPolicy projection() {
|
||||
return new GraphQlRepositoryProjectionPolicy(
|
||||
Set.of("OrderSummaryProjection"), Set.of("OrderEntity"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an unregistered repository is refused")
|
||||
void unregisteredRepositoryIsRejected() {
|
||||
GraphQlRepositoryExposureValidator validator =
|
||||
new GraphQlRepositoryExposureValidator(GraphQlRepositoryAllowlist.empty());
|
||||
|
||||
assertThatThrownBy(() -> validator.verify(ORDERS))
|
||||
.isInstanceOf(GraphQlRepositoryExposureRejectedException.class)
|
||||
.hasMessageContaining("not by an annotation on a persistence class");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the same repository at an unregistered coordinate is still refused")
|
||||
void coordinateIsPartOfTheDecision() {
|
||||
// Spring binds a repository to whatever coordinate its return type matches, so the same
|
||||
// repository reached from two fields is two exposures with two audiences.
|
||||
GraphQlRepositoryExposureValidator validator =
|
||||
new GraphQlRepositoryExposureValidator(
|
||||
allowlistWith(
|
||||
GraphQlRepositoryArgumentPolicy.none(),
|
||||
new GraphQlRepositoryPaginationPolicy(true, 50),
|
||||
projection()));
|
||||
|
||||
assertThat(validator.verify(ORDERS)).isNotNull();
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
validator.verify(
|
||||
new GraphQlRepositoryExposure("OrderRepository", "Query.allOrders")))
|
||||
.isInstanceOf(GraphQlRepositoryExposureRejectedException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("returning the entity is refused, however it is reached")
|
||||
void entityReturnIsRefused() {
|
||||
// The rule that keeps this compatibility path from becoming a database export: every column
|
||||
// published, and a rename in the database a breaking change for every client.
|
||||
GraphQlRepositoryExposureValidator validator =
|
||||
new GraphQlRepositoryExposureValidator(
|
||||
allowlistWith(
|
||||
GraphQlRepositoryArgumentPolicy.none(),
|
||||
new GraphQlRepositoryPaginationPolicy(true, 50),
|
||||
projection()));
|
||||
|
||||
assertThatThrownBy(() -> validator.verifyRequest(ORDERS, Set.of(), Set.of(), 10, "OrderEntity"))
|
||||
.isInstanceOf(GraphQlRepositoryExposureRejectedException.class)
|
||||
.hasMessageContaining("publishes the persistence model as an API");
|
||||
validator.verifyRequest(ORDERS, Set.of(), Set.of(), 10, "OrderSummaryProjection");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a filter the coordinate did not register is refused")
|
||||
void unregisteredFilterIsRefused() {
|
||||
// A query-by-example fetcher derives its predicate from whatever arrives, and the binder
|
||||
// resolves against the entity — so an un-allowlisted argument filters on columns the schema
|
||||
// never published.
|
||||
GraphQlRepositoryExposureValidator validator =
|
||||
new GraphQlRepositoryExposureValidator(
|
||||
allowlistWith(
|
||||
new GraphQlRepositoryArgumentPolicy(Set.of("status"), Set.of("placedAt")),
|
||||
new GraphQlRepositoryPaginationPolicy(true, 50),
|
||||
projection()));
|
||||
|
||||
validator.verifyRequest(
|
||||
ORDERS, Set.of("status"), Set.of("placedAt"), 10, "OrderSummaryProjection");
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
validator.verifyRequest(
|
||||
ORDERS, Set.of("internalCostBasis"), Set.of(), 10, "OrderSummaryProjection"))
|
||||
.isInstanceOf(GraphQlRepositoryExposureRejectedException.class)
|
||||
.hasMessageContaining("filter:internalCostBasis");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("filter and sort are allowlisted separately")
|
||||
void filterAndSortAreSeparate() {
|
||||
// They read alike and behave differently under load: a filter on an unindexed column is one
|
||||
// slow query, a sort on one is a full table sort on every page.
|
||||
GraphQlRepositoryExposureValidator validator =
|
||||
new GraphQlRepositoryExposureValidator(
|
||||
allowlistWith(
|
||||
new GraphQlRepositoryArgumentPolicy(Set.of("status"), Set.of()),
|
||||
new GraphQlRepositoryPaginationPolicy(true, 50),
|
||||
projection()));
|
||||
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
validator.verifyRequest(
|
||||
ORDERS, Set.of("status"), Set.of("status"), 10, "OrderSummaryProjection"))
|
||||
.isInstanceOf(GraphQlRepositoryExposureRejectedException.class)
|
||||
.hasMessageContaining("sort:status");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("every violation is reported at once")
|
||||
void violationsAreReportedTogether() {
|
||||
GraphQlRepositoryExposureValidator validator =
|
||||
new GraphQlRepositoryExposureValidator(
|
||||
allowlistWith(
|
||||
GraphQlRepositoryArgumentPolicy.none(),
|
||||
new GraphQlRepositoryPaginationPolicy(true, 20),
|
||||
projection()));
|
||||
|
||||
assertThatThrownBy(
|
||||
() -> validator.verifyRequest(ORDERS, Set.of("a"), Set.of("b"), 500, "OrderEntity"))
|
||||
.isInstanceOf(GraphQlRepositoryExposureRejectedException.class)
|
||||
.hasMessageContaining("filter:a")
|
||||
.hasMessageContaining("sort:b")
|
||||
.hasMessageContaining("page size 500")
|
||||
.hasMessageContaining("not an approved projection");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a page size beyond the coordinate's limit is refused")
|
||||
void oversizedPageIsRefused() {
|
||||
GraphQlRepositoryPaginationPolicy policy = new GraphQlRepositoryPaginationPolicy(true, 50);
|
||||
|
||||
assertThat(policy.permits(50)).isTrue();
|
||||
assertThat(policy.permits(51)).isFalse();
|
||||
assertThat(policy.permits(0)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a page size that turns a query into a bulk export is refused at configuration")
|
||||
void bulkExportPageSizeIsRefused() {
|
||||
assertThatThrownBy(() -> new GraphQlRepositoryPaginationPolicy(true, 5_000))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("designed for it");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a coordinate with no approved projection is refused at configuration")
|
||||
void projectionlessCoordinateIsRefused() {
|
||||
// It could only return the entity, which is the one thing the policy exists to prevent.
|
||||
assertThatThrownBy(() -> new GraphQlRepositoryProjectionPolicy(Set.of(), Set.of("OrderEntity")))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("this policy exists to prevent");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("two registrations for one coordinate are refused")
|
||||
void duplicateRegistrationIsRefused() {
|
||||
assertThatThrownBy(
|
||||
() ->
|
||||
GraphQlRepositoryAllowlist.builder()
|
||||
.expose(
|
||||
ORDERS,
|
||||
GraphQlRepositoryArgumentPolicy.none(),
|
||||
new GraphQlRepositoryPaginationPolicy(true, 20),
|
||||
projection())
|
||||
.expose(
|
||||
ORDERS,
|
||||
GraphQlRepositoryArgumentPolicy.none(),
|
||||
new GraphQlRepositoryPaginationPolicy(true, 200),
|
||||
projection()))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("silently decide the coordinate's limits");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("nothing is exposed until a deployment says so")
|
||||
void nothingIsExposedByDefault() {
|
||||
GraphQlRepositoryAllowlist empty = GraphQlRepositoryAllowlist.empty();
|
||||
|
||||
assertThat(empty.size()).isZero();
|
||||
assertThat(empty.contains("OrderRepository")).isFalse();
|
||||
assertThat(new GraphQlRepositoryExposureValidator(empty).anyExposure()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("an exposure names a repository and a Type.field coordinate")
|
||||
void exposureShapeIsChecked() {
|
||||
assertThatThrownBy(() -> new GraphQlRepositoryExposure("OrderRepository", "orders"))
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("Type.field");
|
||||
assertThatThrownBy(() -> new GraphQlRepositoryExposure(" ", "Query.orders"))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
}
|
||||
@@ -2,14 +2,13 @@
|
||||
# Manual edits can break the build and are not advised.
|
||||
# This file is expected to be part of source control.
|
||||
biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,testCompileClasspath
|
||||
ch.qos.logback:logback-classic:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
ch.qos.logback:logback-core:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.fasterxml.jackson.core:jackson-annotations:2.20=testCompileClasspath,testRuntimeClasspath
|
||||
com.fasterxml:classmate:1.7.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor
|
||||
ch.qos.logback:logback-classic:1.5.38=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
ch.qos.logback:logback-core:1.5.38=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.fasterxml.jackson.core:jackson-annotations:2.21=testCompileClasspath,testRuntimeClasspath
|
||||
com.fasterxml:classmate:1.7.3=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,testAnnotationProcessor
|
||||
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor
|
||||
com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs
|
||||
com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,testCompileClasspath
|
||||
com.github.spotbugs:spotbugs:4.10.2=spotbugs
|
||||
com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs
|
||||
com.google.android:annotations:4.1.1.4=runtimeClasspath,testRuntimeClasspath
|
||||
@@ -40,13 +39,13 @@ com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnno
|
||||
com.google.protobuf:protobuf-java-util:3.25.5=runtimeClasspath,testRuntimeClasspath
|
||||
com.google.protobuf:protobuf-java:3.25.5=annotationProcessor,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
|
||||
com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins
|
||||
com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath
|
||||
com.jayway.jsonpath:json-path:2.10.0=testCompileClasspath,testRuntimeClasspath
|
||||
com.puppycrawl.tools:checkstyle:13.5.0=checkstyle
|
||||
com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath
|
||||
commons-beanutils:commons-beanutils:1.11.0=checkstyle
|
||||
commons-collections:commons-collections:3.2.2=checkstyle
|
||||
commons-io:commons-io:2.21.0=spotbugs
|
||||
commons-logging:commons-logging:1.3.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
commons-logging:commons-logging:1.3.6=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
info.picocli:picocli:4.7.7=checkstyle
|
||||
io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor
|
||||
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor
|
||||
@@ -59,15 +58,15 @@ io.grpc:grpc-protobuf:1.68.1=runtimeClasspath,testCompileClasspath,testRuntimeCl
|
||||
io.grpc:grpc-services:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.grpc:grpc-stub:1.68.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.grpc:grpc-util:1.68.1=runtimeClasspath,testRuntimeClasspath
|
||||
io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.micrometer:micrometer-commons:1.16.7=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.micrometer:micrometer-observation:1.16.7=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.perfmark:perfmark-api:0.27.0=runtimeClasspath,testRuntimeClasspath
|
||||
jakarta.activation:jakarta.activation-api:2.1.4=testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.validation:jakarta.validation-api:3.1.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.xml.bind:jakarta.xml.bind-api:4.0.5=testCompileClasspath,testRuntimeClasspath
|
||||
javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor
|
||||
jaxen:jaxen:2.0.0=spotbugs
|
||||
jaxen:jaxen:2.0.6=spotbugs
|
||||
net.bytebuddy:byte-buddy-agent:1.17.8=testCompileClasspath,testRuntimeClasspath
|
||||
net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath
|
||||
net.minidev:accessors-smart:2.6.0=testCompileClasspath,testRuntimeClasspath
|
||||
@@ -80,19 +79,19 @@ org.apache.commons:commons-text:1.15.0=spotbugs
|
||||
org.apache.commons:commons-text:1.3=checkstyle
|
||||
org.apache.httpcomponents:httpclient:4.5.13=checkstyle
|
||||
org.apache.httpcomponents:httpcore:4.4.16=checkstyle
|
||||
org.apache.logging.log4j:log4j-api:2.25.2=compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.logging.log4j:log4j-core:2.25.2=spotbugs
|
||||
org.apache.logging.log4j:log4j-to-slf4j:2.25.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.logging.log4j:log4j-api:2.25.5=compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.logging.log4j:log4j-core:2.25.5=spotbugs
|
||||
org.apache.logging.log4j:log4j-to-slf4j:2.25.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.maven.doxia:doxia-core:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle
|
||||
org.apache.tomcat.embed:tomcat-embed-core:11.0.14=testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.tomcat.embed:tomcat-embed-el:11.0.14=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.tomcat.embed:tomcat-embed-core:11.0.24=testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.tomcat.embed:tomcat-embed-el:11.0.24=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.tomcat.embed:tomcat-embed-websocket:11.0.24=testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.xbean:xbean-reflect:3.7=checkstyle
|
||||
org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath
|
||||
org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath
|
||||
org.assertj:assertj-core:3.27.7=testCompileClasspath,testRuntimeClasspath
|
||||
org.awaitility:awaitility:4.3.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.checkerframework:checker-qual:3.42.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.codehaus.mojo:animal-sniffer-annotations:1.24=runtimeClasspath,testRuntimeClasspath
|
||||
@@ -104,16 +103,16 @@ org.dom4j:dom4j:2.2.0=spotbugs
|
||||
org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.hibernate.validator:hibernate-validator:9.0.1.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.javassist:javassist:3.28.0-GA=checkstyle
|
||||
org.jboss.logging:jboss-logging:3.6.1.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-engine:6.0.1=testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath
|
||||
org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.jboss.logging:jboss-logging:3.6.3.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.jspecify:jspecify:1.0.1=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-api:6.0.3=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-engine:6.0.3=testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-params:6.0.3=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter:6.0.3=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-commons:6.0.3=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-engine:6.0.3=testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-launcher:6.0.3=testRuntimeClasspath
|
||||
org.junit:junit-bom:6.0.3=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit:junit-bom:6.1.0=spotbugs
|
||||
org.mockito:mockito-core:5.20.0=mockitoAgent,testCompileClasspath,testRuntimeClasspath
|
||||
org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath
|
||||
@@ -132,47 +131,46 @@ org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor
|
||||
org.reflections:reflections:0.10.2=checkstyle
|
||||
org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath
|
||||
org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath
|
||||
org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j
|
||||
org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-configuration-processor:4.0.0=annotationProcessor
|
||||
org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-http-converter:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-restclient:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-servlet:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-jackson:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-validation:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-tomcat:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-validation:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-web-server:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-aop:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-beans:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-context:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-core:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-expression:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-test:7.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-web:7.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-webmvc:7.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.slf4j:jul-to-slf4j:2.0.18=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.slf4j:slf4j-api:2.0.18=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath
|
||||
org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j
|
||||
org.slf4j:slf4j-simple:2.0.18=checkstyle
|
||||
org.springframework.boot:spring-boot-autoconfigure:4.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-configuration-processor:4.0.8=annotationProcessor
|
||||
org.springframework.boot:spring-boot-http-converter:4.0.8=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-jackson:4.0.8=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-resttestclient:4.0.8=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-servlet:4.0.8=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-jackson-test:4.0.8=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-jackson:4.0.8=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-logging:4.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-test:4.0.8=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.8=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-tomcat:4.0.8=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-validation:4.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-webmvc-test:4.0.8=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-webmvc:4.0.8=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter:4.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-test-autoconfigure:4.0.8=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-test:4.0.8=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-tomcat:4.0.8=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-validation:4.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-web-server:4.0.8=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-webmvc-test:4.0.8=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-webmvc:4.0.8=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot:4.0.8=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-aop:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-beans:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-context:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-core:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-expression:7.0.9=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-test:7.0.9=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-web:7.0.9=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-webmvc:7.0.9=testCompileClasspath,testRuntimeClasspath
|
||||
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
|
||||
org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath
|
||||
org.yaml:snakeyaml:2.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson.core:jackson-core:3.0.2=testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson.core:jackson-databind:3.0.2=testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson:jackson-bom:3.0.2=testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson.core:jackson-core:3.1.5=testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson.core:jackson-databind:3.1.5=testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson:jackson-bom:3.1.5=testCompileClasspath,testRuntimeClasspath
|
||||
empty=
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
// The inbound HTTP API execution platform design models itself as 23 Stable Gradle modules under
|
||||
// modules/web. This repository's fail-closed module registry outranks that layout, so those modules
|
||||
// are packages here and WebModuleBoundaryTest enforces the design's module dependency table. The
|
||||
// full mapping, and the three other places the design's assumptions were adapted, are in
|
||||
// docs/web/repository-adaptation.md.
|
||||
|
||||
// HTTP / web adapters. Depends on application and shared operational contracts.
|
||||
dependencies {
|
||||
implementation project(':application-core')
|
||||
@@ -16,6 +22,24 @@ dependencies {
|
||||
// never a hand-maintained stale schema). The release-blocking drift gate is
|
||||
// owned by feature-contract-verification-test-suite (planned).
|
||||
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-api:3.0.0'
|
||||
// The two Advanced representations, compile-only on purpose. They were `implementation` first,
|
||||
// on the reasoning that a missing backend would surface as a NoClassDefFoundError at the first
|
||||
// request that negotiated one. That reasoning was wrong about what the jars do: Spring Boot's
|
||||
// Jackson auto-configuration registers an `xmlMapper` and a `cborMapper` the moment each
|
||||
// backend is on the runtime classpath, and Spring registers a message converter for XML with
|
||||
// it. So every deployment got three ObjectMapper beans — which broke `@Autowired ObjectMapper`
|
||||
// in the composition root with an ambiguity — and, worse, silently began parsing
|
||||
// `application/xml` request bodies. An Advanced capability that is off by default had turned
|
||||
// XML deserialization on for everybody, which is the opposite of what the flag promises and an
|
||||
// XXE surface nobody chose.
|
||||
//
|
||||
// Compile-only keeps the factories compiling and their tests running while leaving the runtime
|
||||
// classpath to the deployment that enables the capability. `WebRepresentation.available()` is
|
||||
// what turns the absent backend into a sentence instead of a NoClassDefFoundError.
|
||||
compileOnly 'tools.jackson.dataformat:jackson-dataformat-cbor'
|
||||
compileOnly 'tools.jackson.dataformat:jackson-dataformat-xml'
|
||||
testImplementation 'tools.jackson.dataformat:jackson-dataformat-cbor'
|
||||
testImplementation 'tools.jackson.dataformat:jackson-dataformat-xml'
|
||||
// Fileserver reactive transport. Only the WebFlux framework and Reactor core are declared —
|
||||
// deliberately not spring-boot-starter-webflux, which would put a second embedded server
|
||||
// (reactor-netty) on the runtime classpath. DispatcherServlet stays present, so Spring Boot's
|
||||
@@ -30,9 +54,178 @@ dependencies {
|
||||
tasks.named('test') {
|
||||
useJUnitPlatform {
|
||||
excludeTags 'security-boundary'
|
||||
// The parity gate compares recordings written by three lanes. In `test` alone only one of
|
||||
// them exists, and a gate that fails because the others have not run yet is a gate people
|
||||
// learn to ignore. It runs from `webCrossStackParityTest`, which depends on all three.
|
||||
excludeTags 'web-parity'
|
||||
}
|
||||
}
|
||||
|
||||
// The web platform's reusable ArchUnit rules ship in their own source set, consumed by this leaf's
|
||||
// tests and by the composition root. A rule pack that only its own fixture tests import is verified
|
||||
// as library code and applied to nothing — the shape the JPA testkit had to be corrected out of.
|
||||
strictTestLanes {
|
||||
sourceSet('testkit') { compilesAgainst 'main' }
|
||||
// The Jetty compatibility lane is its own source set because it needs a different embedded
|
||||
// server on the classpath. Two servers in one source set means Spring Boot picks one and the
|
||||
// "Jetty" lane silently runs on Tomcat — a compatibility matrix that certifies the same
|
||||
// container twice.
|
||||
sourceSet('jettyCompatTest') {
|
||||
compilesAgainst 'main', 'testkit'
|
||||
inherits 'implementation'
|
||||
}
|
||||
// Reactor Netty is the Stable WebFlux server baseline and it cannot share a source set with
|
||||
// Tomcat: Spring Boot deduces one application type from the classpath, so with both servers
|
||||
// present the reactive gate would start a servlet container and certify nothing reactive.
|
||||
// Nothing is inherited: the leaf's own `implementation` carries spring-boot-starter-web, and
|
||||
// inheriting it would put Tomcat back on this lane's classpath. Boot then deduces a servlet
|
||||
// application, starts a servlet container, and the reactive gate certifies the servlet stack.
|
||||
// The Nginx lane runs the platform behind a real reverse proxy in a container. Its own source
|
||||
// set because it is the only lane that needs Docker: folding it into `test` would make every
|
||||
// developer's `check` depend on a container runtime, and the usual outcome of that is an
|
||||
// @Disabled that nobody notices has been there for months.
|
||||
sourceSet('nginxProxyTest') {
|
||||
compilesAgainst 'main', 'testkit'
|
||||
inherits 'implementation'
|
||||
}
|
||||
sourceSet('webfluxContractTest') {
|
||||
compilesAgainst 'main', 'testkit'
|
||||
// Inherits nothing. The default is to extend `testImplementation`, which extends the leaf's
|
||||
// own `implementation` and therefore carries spring-boot-starter-web — and with Tomcat on
|
||||
// the classpath Boot deduces a servlet application, starts a servlet container, and the
|
||||
// reactive gate certifies the servlet stack while reporting itself green.
|
||||
inherits()
|
||||
}
|
||||
}
|
||||
|
||||
testkitPublisher {
|
||||
consumedBy 'test'
|
||||
publishAs 'webTestkit'
|
||||
}
|
||||
|
||||
// ArchUnit is declared after the testkit source set exists, because `testkitImplementation` is
|
||||
// created by that declaration.
|
||||
dependencies {
|
||||
testkitImplementation 'com.tngtech.archunit:archunit-junit5:1.3.0'
|
||||
testImplementation 'com.tngtech.archunit:archunit-junit5:1.3.0'
|
||||
|
||||
// Jetty replaces Tomcat for this lane only. The exclusion is what makes the lane mean
|
||||
// something: with both on the classpath Boot starts Tomcat and the lane certifies nothing.
|
||||
jettyCompatTestImplementation('org.springframework.boot:spring-boot-starter-jetty')
|
||||
jettyCompatTestImplementation('org.springframework.boot:spring-boot-starter-test') {
|
||||
exclude group: 'org.springframework.boot', module: 'spring-boot-starter-tomcat'
|
||||
}
|
||||
jettyCompatTestImplementation('org.springframework.boot:spring-boot-starter-web') {
|
||||
exclude group: 'org.springframework.boot', module: 'spring-boot-starter-tomcat'
|
||||
}
|
||||
jettyCompatTestImplementation 'org.springframework.boot:spring-boot-starter-security'
|
||||
jettyCompatTestImplementation 'org.springframework.boot:spring-boot-starter-validation'
|
||||
// The root build adds the launcher to `test` only; a custom lane has to say so itself, or the
|
||||
// executor starts and finds no JUnit Platform.
|
||||
jettyCompatTestRuntimeOnly 'org.junit.platform:junit-platform-launcher'
|
||||
|
||||
// The reactive lane: reactor-netty in, Tomcat out. The exclusion is what makes the lane mean
|
||||
// something — Boot picks the servlet stack when both are present.
|
||||
webfluxContractTestImplementation('org.springframework.boot:spring-boot-starter-webflux') {
|
||||
exclude group: 'org.springframework.boot', module: 'spring-boot-starter-tomcat'
|
||||
}
|
||||
webfluxContractTestImplementation('org.springframework.boot:spring-boot-starter-test') {
|
||||
exclude group: 'org.springframework.boot', module: 'spring-boot-starter-tomcat'
|
||||
}
|
||||
webfluxContractTestImplementation 'org.springframework.boot:spring-boot-starter-validation'
|
||||
webfluxContractTestImplementation 'io.projectreactor:reactor-test'
|
||||
// Explicit because this lane inherits nothing: the platform's auto-configuration references
|
||||
// ObjectMapper, and without Jackson the condition evaluation fails before a server starts.
|
||||
webfluxContractTestImplementation 'org.springframework.boot:spring-boot-starter-jackson'
|
||||
webfluxContractTestImplementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310'
|
||||
webfluxContractTestImplementation project(':application-core')
|
||||
webfluxContractTestImplementation project(':shared-contract')
|
||||
webfluxContractTestRuntimeOnly 'org.junit.platform:junit-platform-launcher'
|
||||
|
||||
// The Nginx lane. Testcontainers starts the proxy; the application runs in this JVM on a
|
||||
// random port and the container reaches it through the host gateway.
|
||||
nginxProxyTestImplementation 'org.springframework.boot:spring-boot-starter-test'
|
||||
nginxProxyTestImplementation 'org.springframework.boot:spring-boot-starter-web'
|
||||
nginxProxyTestImplementation 'org.springframework.boot:spring-boot-starter-security'
|
||||
nginxProxyTestImplementation 'org.springframework.boot:spring-boot-starter-validation'
|
||||
nginxProxyTestImplementation 'org.testcontainers:testcontainers'
|
||||
nginxProxyTestImplementation 'org.testcontainers:testcontainers-junit-jupiter'
|
||||
nginxProxyTestRuntimeOnly 'org.junit.platform:junit-platform-launcher'
|
||||
}
|
||||
|
||||
// The lane task. A release compatibility gate that is not wired to a task is a document.
|
||||
tasks.register('webFluxContractTest', Test) {
|
||||
group = 'verification'
|
||||
description = 'Runs the Stable HTTP contract against a real Reactor Netty.'
|
||||
testClassesDirs = sourceSets.webfluxContractTest.output.classesDirs
|
||||
classpath = sourceSets.webfluxContractTest.runtimeClasspath
|
||||
useJUnitPlatform()
|
||||
failOnNoDiscoveredTests = true
|
||||
outputs.upToDateWhen { false }
|
||||
jvmArgs '-Duser.timezone=UTC'
|
||||
}
|
||||
|
||||
// Docker-gated, and it says so rather than skipping. A lane that quietly passes when the container
|
||||
// runtime is missing is a lane that has been certifying nothing since whenever Docker last broke.
|
||||
tasks.register('webNginxProxyTest', Test) {
|
||||
group = 'verification'
|
||||
description = 'Runs the proxy, prefix and spoofing contract behind a real Nginx.'
|
||||
testClassesDirs = sourceSets.nginxProxyTest.output.classesDirs
|
||||
classpath = sourceSets.nginxProxyTest.runtimeClasspath
|
||||
useJUnitPlatform()
|
||||
failOnNoDiscoveredTests = true
|
||||
outputs.upToDateWhen { false }
|
||||
jvmArgs '-Duser.timezone=UTC'
|
||||
}
|
||||
|
||||
// The cross-stack gate. It depends on every recording lane rather than tolerating a missing one:
|
||||
// a parity check that compares whatever happens to be present would report agreement across a
|
||||
// matrix with a hole in it.
|
||||
tasks.register('webCrossStackParityTest', Test) {
|
||||
group = 'verification'
|
||||
description = 'Compares the wire contract recorded by Tomcat, Jetty and Reactor Netty.'
|
||||
testClassesDirs = sourceSets.test.output.classesDirs
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
useJUnitPlatform {
|
||||
includeTags 'web-parity'
|
||||
}
|
||||
failOnNoDiscoveredTests = true
|
||||
outputs.upToDateWhen { false }
|
||||
jvmArgs '-Duser.timezone=UTC'
|
||||
dependsOn 'test', 'webJettyCompatTest', 'webFluxContractTest'
|
||||
}
|
||||
|
||||
// The Advanced lane. Every capability is off unless a deployment names it, so none of them is
|
||||
// exercised by anything a production deployment runs — which makes a lane that runs them all the
|
||||
// only place a break is noticed before whoever enables it notices.
|
||||
//
|
||||
// They also run inside `test`, deliberately. They are ordinary unit tests, and excluding them from
|
||||
// the PR gate to make this lane look meaningful would mean the PR gate stopped covering a fifth of
|
||||
// the leaf.
|
||||
tasks.register('webAdvancedTest', Test) {
|
||||
group = 'verification'
|
||||
description = 'Runs every web Advanced capability contract.'
|
||||
testClassesDirs = sourceSets.test.output.classesDirs
|
||||
classpath = sourceSets.test.runtimeClasspath
|
||||
useJUnitPlatform {
|
||||
includeTags 'web-advanced'
|
||||
}
|
||||
failOnNoDiscoveredTests = true
|
||||
outputs.upToDateWhen { false }
|
||||
jvmArgs '-Duser.timezone=UTC'
|
||||
}
|
||||
|
||||
tasks.register('webJettyCompatTest', Test) {
|
||||
group = 'verification'
|
||||
description = 'Runs the Stable HTTP contract against a real Jetty instead of Tomcat.'
|
||||
testClassesDirs = sourceSets.jettyCompatTest.output.classesDirs
|
||||
classpath = sourceSets.jettyCompatTest.runtimeClasspath
|
||||
useJUnitPlatform()
|
||||
failOnNoDiscoveredTests = true
|
||||
outputs.upToDateWhen { false }
|
||||
jvmArgs '-Duser.timezone=UTC'
|
||||
}
|
||||
|
||||
strictTestLanes {
|
||||
lane('jpaPersistenceRedactionContractTest') {
|
||||
description = 'Runs the exact persistence error log/trace redaction contract used by JPA evidence.'
|
||||
|
||||
@@ -1,188 +1,277 @@
|
||||
# This is a Gradle generated file for dependency locking.
|
||||
# Manual edits can break the build and are not advised.
|
||||
# This file is expected to be part of source control.
|
||||
biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,testCompileClasspath
|
||||
ch.qos.logback:logback-classic:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
ch.qos.logback:logback-core:1.5.21=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.fasterxml.jackson.core:jackson-annotations:2.20=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.fasterxml.jackson.core:jackson-core:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.fasterxml.jackson.core:jackson-databind:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.fasterxml.jackson:jackson-bom:2.20.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.fasterxml:classmate:1.7.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.github.ben-manes.caffeine:caffeine:3.2.3=annotationProcessor,testAnnotationProcessor
|
||||
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,testAnnotationProcessor
|
||||
biz.aQute.bnd:biz.aQute.bnd.annotation:7.1.0=compileClasspath,jettyCompatTestCompileClasspath,nginxProxyTestCompileClasspath,testCompileClasspath,testkitCompileClasspath,webfluxContractTestCompileClasspath
|
||||
ch.qos.logback:logback-classic:1.5.38=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
ch.qos.logback:logback-core:1.5.38=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
com.fasterxml.jackson.core:jackson-annotations:2.21=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
com.fasterxml.jackson.core:jackson-core:2.21.5=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
com.fasterxml.jackson.core:jackson-databind:2.21.5=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.21.5=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.21.5=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
com.fasterxml.jackson:jackson-bom:2.21.5=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
com.fasterxml.woodstox:woodstox-core:7.1.1=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
com.fasterxml:classmate:1.7.3=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
com.github.ben-manes.caffeine:caffeine:3.2.4=annotationProcessor,jettyCompatTestAnnotationProcessor,nginxProxyTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor,webfluxContractTestAnnotationProcessor
|
||||
com.github.docker-java:docker-java-api:3.7.1=nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath
|
||||
com.github.docker-java:docker-java-transport-zerodep:3.7.1=nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath
|
||||
com.github.docker-java:docker-java-transport:3.7.1=nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath
|
||||
com.github.kevinstern:software-and-algorithms:1.0=annotationProcessor,jettyCompatTestAnnotationProcessor,nginxProxyTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor,webfluxContractTestAnnotationProcessor
|
||||
com.github.spotbugs:spotbugs-annotations:4.10.2=spotbugs
|
||||
com.github.spotbugs:spotbugs-annotations:4.8.6=compileClasspath,testCompileClasspath
|
||||
com.github.spotbugs:spotbugs:4.10.2=spotbugs
|
||||
com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs
|
||||
com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,testAnnotationProcessor
|
||||
com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,testAnnotationProcessor
|
||||
com.google.auto:auto-common:1.2.2=annotationProcessor,testAnnotationProcessor
|
||||
com.google.code.findbugs:jsr305:3.0.2=checkstyle,compileClasspath,spotbugs,testCompileClasspath
|
||||
com.google.auto.service:auto-service-annotations:1.0.1=annotationProcessor,jettyCompatTestAnnotationProcessor,nginxProxyTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor,webfluxContractTestAnnotationProcessor
|
||||
com.google.auto.value:auto-value-annotations:1.9=annotationProcessor,jettyCompatTestAnnotationProcessor,nginxProxyTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor,webfluxContractTestAnnotationProcessor
|
||||
com.google.auto:auto-common:1.2.2=annotationProcessor,jettyCompatTestAnnotationProcessor,nginxProxyTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor,webfluxContractTestAnnotationProcessor
|
||||
com.google.code.findbugs:jsr305:3.0.2=checkstyle,spotbugs
|
||||
com.google.code.gson:gson:2.13.2=spotbugs
|
||||
com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,testCompileClasspath
|
||||
com.google.errorprone:error_prone_annotation:2.49.0=annotationProcessor,jettyCompatTestAnnotationProcessor,nginxProxyTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor,webfluxContractTestAnnotationProcessor
|
||||
com.google.errorprone:error_prone_annotations:2.38.0=compileClasspath,jettyCompatTestCompileClasspath,nginxProxyTestCompileClasspath,testCompileClasspath,testkitCompileClasspath,webfluxContractTestCompileClasspath
|
||||
com.google.errorprone:error_prone_annotations:2.41.0=spotbugs
|
||||
com.google.errorprone:error_prone_annotations:2.47.0=checkstyle
|
||||
com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,testAnnotationProcessor
|
||||
com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,testAnnotationProcessor
|
||||
com.google.guava:guava:33.5.0-jre=annotationProcessor,testAnnotationProcessor
|
||||
com.google.errorprone:error_prone_annotations:2.49.0=annotationProcessor,jettyCompatTestAnnotationProcessor,nginxProxyTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor,webfluxContractTestAnnotationProcessor
|
||||
com.google.errorprone:error_prone_check_api:2.49.0=annotationProcessor,jettyCompatTestAnnotationProcessor,nginxProxyTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor,webfluxContractTestAnnotationProcessor
|
||||
com.google.errorprone:error_prone_core:2.49.0=annotationProcessor,jettyCompatTestAnnotationProcessor,nginxProxyTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor,webfluxContractTestAnnotationProcessor
|
||||
com.google.googlejavaformat:google-java-format:1.35.0=annotationProcessor,jettyCompatTestAnnotationProcessor,nginxProxyTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor,webfluxContractTestAnnotationProcessor
|
||||
com.google.guava:failureaccess:1.0.3=annotationProcessor,checkstyle,jettyCompatTestAnnotationProcessor,nginxProxyTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor,webfluxContractTestAnnotationProcessor
|
||||
com.google.guava:guava:33.5.0-jre=annotationProcessor,jettyCompatTestAnnotationProcessor,nginxProxyTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor,webfluxContractTestAnnotationProcessor
|
||||
com.google.guava:guava:33.6.0-jre=checkstyle
|
||||
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,testAnnotationProcessor
|
||||
com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,testAnnotationProcessor
|
||||
com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,testAnnotationProcessor
|
||||
com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,checkstyle,jettyCompatTestAnnotationProcessor,nginxProxyTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor,webfluxContractTestAnnotationProcessor
|
||||
com.google.j2objc:j2objc-annotations:3.1=annotationProcessor,checkstyle,jettyCompatTestAnnotationProcessor,nginxProxyTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor,webfluxContractTestAnnotationProcessor
|
||||
com.google.protobuf:protobuf-java:4.33.2=annotationProcessor,jettyCompatTestAnnotationProcessor,nginxProxyTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor,webfluxContractTestAnnotationProcessor
|
||||
com.h3xstream.findsecbugs:findsecbugs-plugin:1.14.0=spotbugsPlugins
|
||||
com.jayway.jsonpath:json-path:2.9.0=testCompileClasspath,testRuntimeClasspath
|
||||
com.nimbusds:nimbus-jose-jwt:10.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
com.jayway.jsonpath:json-path:2.10.0=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
com.nimbusds:nimbus-jose-jwt:10.4=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
com.puppycrawl.tools:checkstyle:13.5.0=checkstyle
|
||||
com.vaadin.external.google:android-json:0.0.20131108.vaadin1=testCompileClasspath,testRuntimeClasspath
|
||||
com.tngtech.archunit:archunit-junit5-api:1.3.0=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
com.tngtech.archunit:archunit-junit5-engine-api:1.3.0=jettyCompatTestRuntimeClasspath,nginxProxyTestRuntimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath
|
||||
com.tngtech.archunit:archunit-junit5-engine:1.3.0=jettyCompatTestRuntimeClasspath,nginxProxyTestRuntimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath
|
||||
com.tngtech.archunit:archunit-junit5:1.3.0=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
com.tngtech.archunit:archunit:1.3.0=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
com.vaadin.external.google:android-json:0.0.20131108.vaadin1=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
commons-beanutils:commons-beanutils:1.11.0=checkstyle
|
||||
commons-codec:commons-codec:1.19.0=nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath
|
||||
commons-collections:commons-collections:3.2.2=checkstyle
|
||||
commons-io:commons-io:2.20.0=nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath
|
||||
commons-io:commons-io:2.21.0=spotbugs
|
||||
commons-logging:commons-logging:1.3.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
commons-logging:commons-logging:1.3.6=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
info.picocli:picocli:4.7.7=checkstyle
|
||||
io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,testAnnotationProcessor
|
||||
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,testAnnotationProcessor
|
||||
io.micrometer:micrometer-commons:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.micrometer:micrometer-observation:1.16.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.projectreactor:reactor-core:3.8.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.projectreactor:reactor-test:3.8.0=testCompileClasspath,testRuntimeClasspath
|
||||
io.swagger.core.v3:swagger-annotations-jakarta:2.2.38=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.swagger.core.v3:swagger-core-jakarta:2.2.38=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
io.swagger.core.v3:swagger-models-jakarta:2.2.38=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.activation:jakarta.activation-api:2.1.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.validation:jakarta.validation-api:3.1.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
jakarta.xml.bind:jakarta.xml.bind-api:4.0.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
javax.inject:javax.inject:1=annotationProcessor,testAnnotationProcessor
|
||||
jaxen:jaxen:2.0.0=spotbugs
|
||||
net.bytebuddy:byte-buddy-agent:1.17.8=testCompileClasspath,testRuntimeClasspath
|
||||
net.bytebuddy:byte-buddy:1.17.8=testCompileClasspath,testRuntimeClasspath
|
||||
net.minidev:accessors-smart:2.6.0=testCompileClasspath,testRuntimeClasspath
|
||||
net.minidev:json-smart:2.6.0=testCompileClasspath,testRuntimeClasspath
|
||||
io.github.eisop:dataflow-errorprone:3.41.0-eisop1=annotationProcessor,jettyCompatTestAnnotationProcessor,nginxProxyTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor,webfluxContractTestAnnotationProcessor
|
||||
io.github.java-diff-utils:java-diff-utils:4.12=annotationProcessor,jettyCompatTestAnnotationProcessor,nginxProxyTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor,webfluxContractTestAnnotationProcessor
|
||||
io.micrometer:micrometer-commons:1.16.7=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
io.micrometer:micrometer-observation:1.16.7=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
io.netty:netty-buffer:4.2.17.Final=webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
io.netty:netty-codec-base:4.2.17.Final=webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
io.netty:netty-codec-classes-quic:4.2.17.Final=webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
io.netty:netty-codec-compression:4.2.17.Final=webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
io.netty:netty-codec-dns:4.2.17.Final=webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
io.netty:netty-codec-http2:4.2.17.Final=webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
io.netty:netty-codec-http3:4.2.17.Final=webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
io.netty:netty-codec-http:4.2.17.Final=webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
io.netty:netty-codec-native-quic:4.2.17.Final=webfluxContractTestRuntimeClasspath
|
||||
io.netty:netty-codec-socks:4.2.17.Final=webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
io.netty:netty-common:4.2.17.Final=webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
io.netty:netty-handler-proxy:4.2.17.Final=webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
io.netty:netty-handler:4.2.17.Final=webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
io.netty:netty-resolver-dns-classes-macos:4.2.17.Final=webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
io.netty:netty-resolver-dns-native-macos:4.2.17.Final=webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
io.netty:netty-resolver-dns:4.2.17.Final=webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
io.netty:netty-resolver:4.2.17.Final=webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
io.netty:netty-transport-classes-epoll:4.2.17.Final=webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
io.netty:netty-transport-native-epoll:4.2.17.Final=webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
io.netty:netty-transport-native-unix-common:4.2.17.Final=webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
io.netty:netty-transport:4.2.17.Final=webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
io.projectreactor.netty:reactor-netty-core:1.3.7=webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
io.projectreactor.netty:reactor-netty-http:1.3.7=webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
io.projectreactor:reactor-core:3.8.7=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
io.projectreactor:reactor-test:3.8.7=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
io.swagger.core.v3:swagger-annotations-jakarta:2.2.38=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
io.swagger.core.v3:swagger-core-jakarta:2.2.38=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
io.swagger.core.v3:swagger-models-jakarta:2.2.38=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
jakarta.activation:jakarta.activation-api:2.1.4=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
jakarta.annotation:jakarta.annotation-api:3.0.0=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
jakarta.enterprise:jakarta.enterprise.cdi-api:4.1.0=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath
|
||||
jakarta.enterprise:jakarta.enterprise.lang-model:4.1.0=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath
|
||||
jakarta.inject:jakarta.inject-api:2.0.1=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath
|
||||
jakarta.interceptor:jakarta.interceptor-api:2.2.0=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath
|
||||
jakarta.servlet:jakarta.servlet-api:6.1.0=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath
|
||||
jakarta.transaction:jakarta.transaction-api:2.0.1=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath
|
||||
jakarta.validation:jakarta.validation-api:3.1.1=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
jakarta.websocket:jakarta.websocket-api:2.2.0=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath
|
||||
jakarta.websocket:jakarta.websocket-client-api:2.2.0=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath
|
||||
jakarta.xml.bind:jakarta.xml.bind-api:4.0.5=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
javax.inject:javax.inject:1=annotationProcessor,jettyCompatTestAnnotationProcessor,nginxProxyTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor,webfluxContractTestAnnotationProcessor
|
||||
jaxen:jaxen:2.0.6=spotbugs
|
||||
net.bytebuddy:byte-buddy-agent:1.17.8=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
net.bytebuddy:byte-buddy:1.17.8=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
net.java.dev.jna:jna:5.18.1=nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath
|
||||
net.minidev:accessors-smart:2.6.0=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
net.minidev:json-smart:2.6.0=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
net.sf.saxon:Saxon-HE:12.9=checkstyle,spotbugs
|
||||
org.antlr:antlr4-runtime:4.13.2=checkstyle
|
||||
org.apache.bcel:bcel:6.12.0=spotbugs
|
||||
org.apache.commons:commons-lang3:3.20.0=checkstyle,compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.commons:commons-compress:1.28.0=nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath
|
||||
org.apache.commons:commons-lang3:3.20.0=checkstyle,compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.apache.commons:commons-text:1.15.0=spotbugs
|
||||
org.apache.commons:commons-text:1.3=checkstyle
|
||||
org.apache.httpcomponents:httpclient:4.5.13=checkstyle
|
||||
org.apache.httpcomponents:httpcore:4.4.16=checkstyle
|
||||
org.apache.logging.log4j:log4j-api:2.25.2=compileClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.logging.log4j:log4j-core:2.25.2=spotbugs
|
||||
org.apache.logging.log4j:log4j-to-slf4j:2.25.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.logging.log4j:log4j-api:2.25.5=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,spotbugs,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
org.apache.logging.log4j:log4j-core:2.25.5=spotbugs
|
||||
org.apache.logging.log4j:log4j-to-slf4j:2.25.5=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
org.apache.maven.doxia:doxia-core:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-logging-api:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-module-xdoc:1.12.0=checkstyle
|
||||
org.apache.maven.doxia:doxia-sink-api:1.12.0=checkstyle
|
||||
org.apache.tomcat.embed:tomcat-embed-core:11.0.14=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.tomcat.embed:tomcat-embed-el:11.0.14=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.tomcat.embed:tomcat-embed-websocket:11.0.14=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.apache.tomcat.embed:tomcat-embed-core:11.0.24=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.apache.tomcat.embed:tomcat-embed-el:11.0.24=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
org.apache.tomcat.embed:tomcat-embed-websocket:11.0.24=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.apache.xbean:xbean-reflect:3.7=checkstyle
|
||||
org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath
|
||||
org.assertj:assertj-core:3.27.6=testCompileClasspath,testRuntimeClasspath
|
||||
org.awaitility:awaitility:4.3.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.apiguardian:apiguardian-api:1.1.2=jettyCompatTestCompileClasspath,nginxProxyTestCompileClasspath,testCompileClasspath,testkitCompileClasspath,webfluxContractTestCompileClasspath
|
||||
org.assertj:assertj-core:3.27.7=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
org.awaitility:awaitility:4.3.0=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
org.codehaus.plexus:plexus-classworlds:2.6.0=checkstyle
|
||||
org.codehaus.plexus:plexus-component-annotations:2.1.0=checkstyle
|
||||
org.codehaus.plexus:plexus-container-default:2.1.0=checkstyle
|
||||
org.codehaus.plexus:plexus-utils:3.3.0=checkstyle
|
||||
org.codehaus.woodstox:stax2-api:4.2.2=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.dom4j:dom4j:2.2.0=spotbugs
|
||||
org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.hibernate.validator:hibernate-validator:9.0.1.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.eclipse.jetty.compression:jetty-compression-common:12.1.12=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath
|
||||
org.eclipse.jetty.compression:jetty-compression-gzip:12.1.12=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath
|
||||
org.eclipse.jetty.compression:jetty-compression-server:12.1.12=jettyCompatTestRuntimeClasspath
|
||||
org.eclipse.jetty.ee11.websocket:jetty-ee11-websocket-jakarta-client:12.1.12=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath
|
||||
org.eclipse.jetty.ee11.websocket:jetty-ee11-websocket-jakarta-common:12.1.12=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath
|
||||
org.eclipse.jetty.ee11.websocket:jetty-ee11-websocket-jakarta-server:12.1.12=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath
|
||||
org.eclipse.jetty.ee11.websocket:jetty-ee11-websocket-jetty-server:12.1.12=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath
|
||||
org.eclipse.jetty.ee11.websocket:jetty-ee11-websocket-servlet:12.1.12=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath
|
||||
org.eclipse.jetty.ee11:jetty-ee11-annotations:12.1.12=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath
|
||||
org.eclipse.jetty.ee11:jetty-ee11-plus:12.1.12=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath
|
||||
org.eclipse.jetty.ee11:jetty-ee11-servlet:12.1.12=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath
|
||||
org.eclipse.jetty.ee11:jetty-ee11-webapp:12.1.12=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath
|
||||
org.eclipse.jetty.ee:jetty-ee-webapp:12.1.12=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath
|
||||
org.eclipse.jetty.websocket:jetty-websocket-core-client:12.1.12=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath
|
||||
org.eclipse.jetty.websocket:jetty-websocket-core-common:12.1.12=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath
|
||||
org.eclipse.jetty.websocket:jetty-websocket-core-server:12.1.12=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath
|
||||
org.eclipse.jetty.websocket:jetty-websocket-jetty-api:12.1.12=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath
|
||||
org.eclipse.jetty.websocket:jetty-websocket-jetty-common:12.1.12=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath
|
||||
org.eclipse.jetty.websocket:jetty-websocket-jetty-server:12.1.12=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath
|
||||
org.eclipse.jetty:jetty-alpn-client:12.1.12=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath
|
||||
org.eclipse.jetty:jetty-annotations:12.1.12=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath
|
||||
org.eclipse.jetty:jetty-client:12.1.12=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath
|
||||
org.eclipse.jetty:jetty-http:12.1.12=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath
|
||||
org.eclipse.jetty:jetty-io:12.1.12=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath
|
||||
org.eclipse.jetty:jetty-plus:12.1.12=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath
|
||||
org.eclipse.jetty:jetty-security:12.1.12=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath
|
||||
org.eclipse.jetty:jetty-server:12.1.12=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath
|
||||
org.eclipse.jetty:jetty-session:12.1.12=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath
|
||||
org.eclipse.jetty:jetty-util:12.1.12=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath
|
||||
org.eclipse.jetty:jetty-xml:12.1.12=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath
|
||||
org.hamcrest:hamcrest:3.0=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
org.hibernate.validator:hibernate-validator:9.0.1.Final=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
org.javassist:javassist:3.28.0-GA=checkstyle
|
||||
org.jboss.logging:jboss-logging:3.6.1.Final=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.jspecify:jspecify:1.0.0=annotationProcessor,checkstyle,compileClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-api:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-engine:6.0.1=testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-params:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-commons:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-engine:6.0.1=testRuntimeClasspath
|
||||
org.junit.platform:junit-platform-launcher:6.0.1=testRuntimeClasspath
|
||||
org.junit:junit-bom:6.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.jboss.logging:jboss-logging:3.6.3.Final=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
org.jetbrains:annotations:17.0.0=nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath
|
||||
org.jspecify:jspecify:1.0.1=annotationProcessor,checkstyle,compileClasspath,jettyCompatTestAnnotationProcessor,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestAnnotationProcessor,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath,testkitAnnotationProcessor,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestAnnotationProcessor,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-api:6.0.3=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-engine:6.0.3=jettyCompatTestRuntimeClasspath,nginxProxyTestRuntimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath,webfluxContractTestRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter-params:6.0.3=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
org.junit.jupiter:junit-jupiter:6.0.3=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
org.junit.platform:junit-platform-commons:6.0.3=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
org.junit.platform:junit-platform-engine:6.0.3=jettyCompatTestRuntimeClasspath,nginxProxyTestRuntimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath,webfluxContractTestRuntimeClasspath
|
||||
org.junit.platform:junit-platform-launcher:6.0.3=jettyCompatTestRuntimeClasspath,nginxProxyTestRuntimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath,webfluxContractTestRuntimeClasspath
|
||||
org.junit:junit-bom:6.0.3=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
org.junit:junit-bom:6.1.0=spotbugs
|
||||
org.mockito:mockito-core:5.20.0=mockitoAgent,testCompileClasspath,testRuntimeClasspath
|
||||
org.mockito:mockito-junit-jupiter:5.20.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.objenesis:objenesis:3.3=testRuntimeClasspath
|
||||
org.openapitools:jackson-databind-nullable:0.2.6=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,testCompileClasspath
|
||||
org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,testCompileClasspath
|
||||
org.osgi:org.osgi.resource:1.0.0=compileClasspath,testCompileClasspath
|
||||
org.osgi:org.osgi.service.serviceloader:1.0.0=compileClasspath,testCompileClasspath
|
||||
org.mockito:mockito-core:5.20.0=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,mockitoAgent,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
org.mockito:mockito-junit-jupiter:5.20.0=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
org.objenesis:objenesis:3.3=jettyCompatTestRuntimeClasspath,nginxProxyTestRuntimeClasspath,testRuntimeClasspath,testkitRuntimeClasspath,webfluxContractTestRuntimeClasspath
|
||||
org.openapitools:jackson-databind-nullable:0.2.6=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.opentest4j:opentest4j:1.3.0=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
org.osgi:org.osgi.annotation.bundle:2.0.0=compileClasspath,jettyCompatTestCompileClasspath,nginxProxyTestCompileClasspath,testCompileClasspath,testkitCompileClasspath,webfluxContractTestCompileClasspath
|
||||
org.osgi:org.osgi.annotation.versioning:1.1.2=compileClasspath,jettyCompatTestCompileClasspath,nginxProxyTestCompileClasspath,testCompileClasspath,testkitCompileClasspath,webfluxContractTestCompileClasspath
|
||||
org.osgi:org.osgi.resource:1.0.0=compileClasspath,jettyCompatTestCompileClasspath,nginxProxyTestCompileClasspath,testCompileClasspath,testkitCompileClasspath,webfluxContractTestCompileClasspath
|
||||
org.osgi:org.osgi.service.serviceloader:1.0.0=compileClasspath,jettyCompatTestCompileClasspath,nginxProxyTestCompileClasspath,testCompileClasspath,testkitCompileClasspath,webfluxContractTestCompileClasspath
|
||||
org.ow2.asm:asm-analysis:9.10.1=spotbugs
|
||||
org.ow2.asm:asm-commons:9.10.1=spotbugs
|
||||
org.ow2.asm:asm-tree:9.10.1=spotbugs
|
||||
org.ow2.asm:asm-commons:9.10.1=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,spotbugs
|
||||
org.ow2.asm:asm-tree:9.10.1=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,spotbugs
|
||||
org.ow2.asm:asm-util:9.10.1=spotbugs
|
||||
org.ow2.asm:asm:9.10.1=spotbugs
|
||||
org.ow2.asm:asm:9.7.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.pcollections:pcollections:4.0.1=annotationProcessor,testAnnotationProcessor
|
||||
org.reactivestreams:reactive-streams:1.0.4=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.ow2.asm:asm:9.10.1=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,spotbugs
|
||||
org.ow2.asm:asm:9.7.1=nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
org.pcollections:pcollections:4.0.1=annotationProcessor,jettyCompatTestAnnotationProcessor,nginxProxyTestAnnotationProcessor,testAnnotationProcessor,testkitAnnotationProcessor,webfluxContractTestAnnotationProcessor
|
||||
org.reactivestreams:reactive-streams:1.0.4=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
org.reflections:reflections:0.10.2=checkstyle
|
||||
org.skyscreamer:jsonassert:1.5.3=testCompileClasspath,testRuntimeClasspath
|
||||
org.slf4j:jul-to-slf4j:2.0.17=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.slf4j:slf4j-api:2.0.17=compileClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath
|
||||
org.slf4j:slf4j-simple:2.0.17=checkstyle,spotbugsSlf4j
|
||||
org.springdoc:springdoc-openapi-starter-common:3.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springdoc:springdoc-openapi-starter-webmvc-api:3.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-autoconfigure:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-configuration-processor:4.0.0=annotationProcessor
|
||||
org.springframework.boot:spring-boot-http-client:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-http-converter:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-jackson:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-restclient:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-resttestclient:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-security-oauth2-resource-server:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-security:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-servlet:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-jackson-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-jackson:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-logging:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-oauth2-resource-server:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-security:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-tomcat:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-validation:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-web:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-webmvc:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-test-autoconfigure:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-tomcat:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-validation:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-web-server:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-webmvc-test:4.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-webmvc:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.boot:spring-boot:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.security:spring-security-config:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.security:spring-security-core:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.security:spring-security-crypto:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.security:spring-security-oauth2-core:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.security:spring-security-oauth2-jose:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.security:spring-security-oauth2-resource-server:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.security:spring-security-test:7.0.0=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.security:spring-security-web:7.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework.session:spring-session-core:4.0.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-aop:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-beans:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-context:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-core:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-expression:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-test:7.0.1=testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-web:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-webflux:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.springframework:spring-webmvc:7.0.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.rnorth.duct-tape:duct-tape:1.0.8=nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath
|
||||
org.skyscreamer:jsonassert:1.5.3=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
org.slf4j:jul-to-slf4j:2.0.18=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
org.slf4j:slf4j-api:2.0.18=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,spotbugs,spotbugsSlf4j,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j
|
||||
org.slf4j:slf4j-simple:2.0.18=checkstyle
|
||||
org.springdoc:springdoc-openapi-starter-common:3.0.0=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.springdoc:springdoc-openapi-starter-webmvc-api:3.0.0=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-autoconfigure:4.0.8=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-configuration-processor:4.0.8=annotationProcessor
|
||||
org.springframework.boot:spring-boot-http-codec:4.0.8=webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-http-converter:4.0.8=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-jackson:4.0.8=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-jetty:4.0.8=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-netty:4.0.8=webfluxContractTestRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-reactor-netty:4.0.8=webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-reactor:4.0.8=webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-resttestclient:4.0.8=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-security-oauth2-resource-server:4.0.8=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-security:4.0.8=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-servlet:4.0.8=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-jackson-test:4.0.8=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-jackson:4.0.8=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-jetty-runtime:4.0.8=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-jetty:4.0.8=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-logging:4.0.8=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-oauth2-resource-server:4.0.8=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-reactor-netty:4.0.8=webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-security:4.0.8=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-test:4.0.8=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-tomcat-runtime:4.0.8=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-tomcat:4.0.8=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-validation:4.0.8=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-web:4.0.8=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-webflux:4.0.8=webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-webmvc-test:4.0.8=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter-webmvc:4.0.8=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-starter:4.0.8=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-test-autoconfigure:4.0.8=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-test:4.0.8=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-tomcat:4.0.8=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-validation:4.0.8=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-web-server:4.0.8=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-webflux:4.0.8=webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-webmvc-test:4.0.8=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.springframework.boot:spring-boot-webmvc:4.0.8=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.springframework.boot:spring-boot:4.0.8=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
org.springframework.security:spring-security-config:7.0.7=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.springframework.security:spring-security-core:7.0.7=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.springframework.security:spring-security-crypto:7.0.7=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.springframework.security:spring-security-oauth2-core:7.0.7=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.springframework.security:spring-security-oauth2-jose:7.0.7=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.springframework.security:spring-security-oauth2-resource-server:7.0.7=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.springframework.security:spring-security-test:7.0.7=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.springframework.security:spring-security-web:7.0.7=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.springframework.session:spring-session-core:4.0.5=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.springframework:spring-aop:7.0.9=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
org.springframework:spring-beans:7.0.9=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
org.springframework:spring-context:7.0.9=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
org.springframework:spring-core:7.0.9=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
org.springframework:spring-expression:7.0.9=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
org.springframework:spring-test:7.0.9=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
org.springframework:spring-web:7.0.9=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
org.springframework:spring-webflux:7.0.9=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
org.springframework:spring-webmvc:7.0.9=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
org.testcontainers:testcontainers-junit-jupiter:2.0.5=nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath
|
||||
org.testcontainers:testcontainers:2.0.5=nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath
|
||||
org.xmlresolver:xmlresolver:5.3.3=checkstyle,spotbugs
|
||||
org.xmlunit:xmlunit-core:2.10.4=testCompileClasspath,testRuntimeClasspath
|
||||
org.yaml:snakeyaml:2.5=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson.core:jackson-core:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson.core:jackson-databind:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
tools.jackson:jackson-bom:3.0.2=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
|
||||
org.xmlunit:xmlunit-core:2.10.4=jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
org.yaml:snakeyaml:2.5=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
tools.jackson.core:jackson-core:3.1.5=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
tools.jackson.core:jackson-databind:3.1.5=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
tools.jackson.dataformat:jackson-dataformat-cbor:3.1.5=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
tools.jackson.dataformat:jackson-dataformat-xml:3.1.5=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath
|
||||
tools.jackson:jackson-bom:3.1.5=compileClasspath,jettyCompatTestCompileClasspath,jettyCompatTestRuntimeClasspath,nginxProxyTestCompileClasspath,nginxProxyTestRuntimeClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath,testkitCompileClasspath,testkitRuntimeClasspath,webfluxContractTestCompileClasspath,webfluxContractTestRuntimeClasspath
|
||||
empty=
|
||||
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package dev.caskeleton.adapter.inbound.web.testkit.budget;
|
||||
|
||||
import dev.caskeleton.webtestkit.BudgetFixtureApplication;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.web.server.LocalServerPort;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
|
||||
/**
|
||||
* Budget enforcement on Jetty.
|
||||
*
|
||||
* <p>Containers impose bounds of their own — on the request line, on header size, on how a body is
|
||||
* fed to a filter — and they differ. Running the same contract on the second one is what tells us
|
||||
* whether a refusal came from the platform or from Tomcat.
|
||||
*/
|
||||
@SpringBootTest(
|
||||
classes = BudgetFixtureApplication.class,
|
||||
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
|
||||
// The budget handler is gated on this property. Turning it on here rather than defaulting it
|
||||
// on keeps the production default off: a control that is on by default is one nobody notices.
|
||||
properties = "backend.web.budgets.enabled=true")
|
||||
@ActiveProfiles("web-contract")
|
||||
class JettyWebBudgetIT extends WebBudgetContract {
|
||||
|
||||
@LocalServerPort private int port;
|
||||
|
||||
@Override
|
||||
protected HttpBudgetFixture fixture() {
|
||||
return new HttpBudgetFixture(port);
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package dev.caskeleton.adapter.inbound.web.testkit.contract;
|
||||
|
||||
import dev.caskeleton.webtestkit.ContractFixtureApplication;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.web.server.LocalServerPort;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
|
||||
/** Records the wire contract as the second servlet container serves it. */
|
||||
@SpringBootTest(
|
||||
classes = ContractFixtureApplication.class,
|
||||
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||
@ActiveProfiles("web-contract")
|
||||
class JettyContractRecordingIT extends WebPlatformContractRecording {
|
||||
|
||||
@LocalServerPort private int port;
|
||||
|
||||
@Override
|
||||
protected WebContractFixture fixture() {
|
||||
return new WebContractFixture(port);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String laneName() {
|
||||
return "jetty";
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package dev.caskeleton.adapter.inbound.web.testkit.fault;
|
||||
|
||||
import dev.caskeleton.webtestkit.ContractFixtureApplication;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.web.server.LocalServerPort;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
|
||||
/**
|
||||
* The response-loss contract on Jetty.
|
||||
*
|
||||
* <p>Run on the second container because losing a response is exactly where containers differ: they
|
||||
* detect a departed client at different moments and unwind the request differently. The contract
|
||||
* they must both keep — the write happened once, the retry recovers it — is the same, so it is
|
||||
* asserted from the same shared class rather than restated here.
|
||||
*/
|
||||
@SpringBootTest(
|
||||
classes = ContractFixtureApplication.class,
|
||||
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||
@ActiveProfiles("web-contract")
|
||||
class JettyCommitThenConnectionResetIT extends IdempotencyResponseLossContract {
|
||||
|
||||
@LocalServerPort private int port;
|
||||
|
||||
@Override
|
||||
protected ResponseLossFixture fixture() {
|
||||
return new HttpResponseLossFixture(port);
|
||||
}
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
package dev.caskeleton.adapter.inbound.web.testkit.mvc;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import dev.caskeleton.webtestkit.ContractFixtureApplication;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.web.server.LocalServerPort;
|
||||
import org.springframework.boot.web.server.servlet.context.ServletWebServerApplicationContext;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
|
||||
/**
|
||||
* The same Stable HTTP contract, on Jetty.
|
||||
*
|
||||
* <p>The same one: {@link WebContractAssertions} is shared with the Tomcat lane rather than copied,
|
||||
* because the design's rule is that container-specific behaviour is never imported back into the
|
||||
* common core, and two copies is how the second one quietly becomes a different contract.
|
||||
*
|
||||
* <p>The first assertion checks which server actually started. Without it a classpath change that
|
||||
* put Tomcat back would leave this lane green while certifying the same container twice — the
|
||||
* failure mode a compatibility matrix exists to prevent and the one it is worst at detecting.
|
||||
*/
|
||||
@SpringBootTest(
|
||||
classes = ContractFixtureApplication.class,
|
||||
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||
@ActiveProfiles("web-contract")
|
||||
class JettyWebContractIT {
|
||||
|
||||
@LocalServerPort private int port;
|
||||
|
||||
@Autowired private ServletWebServerApplicationContext context;
|
||||
|
||||
@Test
|
||||
@DisplayName("the lane is actually running on Jetty")
|
||||
void theLaneIsActuallyRunningOnJetty() {
|
||||
assertThat(context.getWebServer().getClass().getName())
|
||||
.as("with Tomcat still on the classpath this lane would certify the same container twice")
|
||||
.contains("Jetty");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("the whole Stable HTTP contract holds on Jetty")
|
||||
void theWholeStableContractHoldsOnJetty() throws Exception {
|
||||
new WebContractAssertions(port).assertWholeContract();
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package dev.caskeleton.adapter.inbound.web.testkit.operation;
|
||||
|
||||
import dev.caskeleton.webtestkit.ContractFixtureApplication;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.web.server.LocalServerPort;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
|
||||
/**
|
||||
* The operation resource contract on Jetty.
|
||||
*
|
||||
* <p>Containers differ in how they normalise a {@code Location} and whether they keep a {@code
|
||||
* Content-Location} on a 200. Those are exactly the headers this contract turns on, so running it
|
||||
* on the second servlet container is not redundancy.
|
||||
*/
|
||||
@SpringBootTest(
|
||||
classes = ContractFixtureApplication.class,
|
||||
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||
@ActiveProfiles("web-contract")
|
||||
class JettyOperationHttpIT extends OperationHttpContract {
|
||||
|
||||
@LocalServerPort private int port;
|
||||
|
||||
@Override
|
||||
protected HttpOperationFixture fixture() {
|
||||
return new HttpOperationFixture(port);
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package dev.caskeleton.adapter.inbound.web.testkit.order;
|
||||
|
||||
import dev.caskeleton.webtestkit.PipelineOrderFixtureApplication;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.web.server.LocalServerPort;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
|
||||
/**
|
||||
* Pipeline order on Jetty.
|
||||
*
|
||||
* <p>Async redispatch is a servlet-container mechanism, and the two containers implement it
|
||||
* separately — including when {@code isAsyncStarted} becomes true relative to the filter chain
|
||||
* unwinding. The duplicate-observation guard turns on exactly that timing, so certifying it on one
|
||||
* container says nothing about the other.
|
||||
*/
|
||||
@SpringBootTest(
|
||||
classes = PipelineOrderFixtureApplication.class,
|
||||
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||
@ActiveProfiles("web-contract")
|
||||
class JettyPipelineOrderIT extends WebPipelineOrderContract {
|
||||
|
||||
@LocalServerPort private int port;
|
||||
|
||||
@Override
|
||||
protected HttpPipelineFixture fixture() {
|
||||
return new HttpPipelineFixture(port);
|
||||
}
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
package dev.caskeleton.adapter.inbound.web.testkit.performance;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import dev.caskeleton.webtestkit.ContractFixtureApplication;
|
||||
import java.time.Duration;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.web.server.LocalServerPort;
|
||||
import org.springframework.boot.web.server.servlet.context.ServletWebServerApplicationContext;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
|
||||
/**
|
||||
* Load, abuse and graceful shutdown on the the second servlet container.
|
||||
*
|
||||
* <p>Shutdown is asserted per container rather than once, because it is implemented per container.
|
||||
* "Stop accepting, finish what is in flight" is a promise each server keeps in its own way, and the
|
||||
* failure — a request cut off mid-response during a rolling deploy — looks to the client exactly
|
||||
* like the network.
|
||||
*/
|
||||
@SpringBootTest(
|
||||
classes = ContractFixtureApplication.class,
|
||||
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
|
||||
properties = "server.shutdown=graceful")
|
||||
@ActiveProfiles("web-contract")
|
||||
class JettyLoadAndShutdownIT extends WebLoadAndShutdownContract {
|
||||
|
||||
@LocalServerPort private int port;
|
||||
|
||||
@Autowired private ServletWebServerApplicationContext context;
|
||||
|
||||
@Override
|
||||
protected WebLoadFixture fixture() {
|
||||
return new WebLoadFixture(port);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected org.springframework.boot.web.server.WebServer webServer() {
|
||||
return context.getWebServer();
|
||||
}
|
||||
|
||||
@Test
|
||||
@Tag("web-shutdown")
|
||||
@DisplayName("graceful shutdown drains within the deployment's grace period")
|
||||
void gracefulShutdownFinishesInFlightWork() {
|
||||
// Last, and destructive: the context serves nothing afterwards.
|
||||
WebLoadFixture fixture = fixture();
|
||||
assertThat(fixture.stillServing(loadPath())).isTrue();
|
||||
|
||||
GracefulShutdownProbe.Outcome outcome =
|
||||
GracefulShutdownProbe.shutDown(webServer(), Duration.ofSeconds(10));
|
||||
|
||||
// Bounded on purpose. A shutdown that waits indefinitely for a connection to go idle is how a
|
||||
// rolling deploy stalls with half the fleet drained — and it produces no error to alert on.
|
||||
assertThat(outcome.took()).isLessThan(Duration.ofSeconds(15));
|
||||
assertThat(fixture.stillServing(loadPath()))
|
||||
.as("the server accepted a new request after it was told to stop")
|
||||
.isFalse();
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package dev.caskeleton.adapter.inbound.web.testkit.throttle;
|
||||
|
||||
import dev.caskeleton.webtestkit.ThrottleFixtureApplication;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.web.server.LocalServerPort;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
|
||||
/**
|
||||
* Quota and capacity refusals on the second servlet container.
|
||||
*
|
||||
* <p>Real, because the capacity case needs one request to genuinely occupy a slot while another
|
||||
* arrives. A mock dispatcher runs them one after the other, so the second never meets a full
|
||||
* service and the 503 case would pass without ever having been exercised.
|
||||
*/
|
||||
@SpringBootTest(
|
||||
classes = ThrottleFixtureApplication.class,
|
||||
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||
@ActiveProfiles("web-contract")
|
||||
class JettyWebThrottleIT extends WebThrottleHttpContract {
|
||||
|
||||
@LocalServerPort private int port;
|
||||
|
||||
private HttpThrottleFixture fixture;
|
||||
|
||||
@Override
|
||||
protected HttpThrottleFixture fixture() {
|
||||
if (fixture == null) {
|
||||
fixture = new HttpThrottleFixture(port);
|
||||
}
|
||||
return fixture;
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void closeFixture() {
|
||||
if (fixture != null) {
|
||||
fixture.close();
|
||||
fixture = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
# The profile the real-container contract gate runs under.
|
||||
#
|
||||
# Everything is switched off except the servlet transport itself. The gate is about the status
|
||||
# contract on the wire, and a security chain or a file-server profile joining the context would make
|
||||
# a failure here ambiguous between "the contract broke" and "an unrelated capability did".
|
||||
spring:
|
||||
main:
|
||||
banner-mode: "off"
|
||||
mvc:
|
||||
problemdetails:
|
||||
enabled: true
|
||||
server:
|
||||
error:
|
||||
include-stacktrace: never
|
||||
include-message: never
|
||||
backend:
|
||||
web:
|
||||
mvc:
|
||||
enabled: true
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package dev.caskeleton.adapter.inbound.web.admin.platform;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* What the platform is actually configured to do, as an operator can read it.
|
||||
*
|
||||
* <p>Read from the running objects, never from the configuration that was supposed to produce them.
|
||||
* Every incident where a control was "enabled" and did nothing comes down to the same gap: the
|
||||
* property was set, the bean was not created, and the only thing anyone could inspect was the
|
||||
* property. A snapshot built from the beans reports the second fact.
|
||||
*
|
||||
* @param transport which stack is serving, "servlet" or "reactive"
|
||||
* @param apiVersions the API major versions being served
|
||||
* @param problemCodes every failure code this deployment can publish
|
||||
* @param budgetProfiles the registered request budgets, by name
|
||||
* @param admissionProfiles the registered admission profiles, by name
|
||||
* @param cacheProfiles the registered cache profiles, by name
|
||||
* @param installedControls which platform controls are actually wired, by name
|
||||
*/
|
||||
public record WebPlatformSnapshot(
|
||||
String transport,
|
||||
List<Integer> apiVersions,
|
||||
List<String> problemCodes,
|
||||
Map<String, String> budgetProfiles,
|
||||
Map<String, String> admissionProfiles,
|
||||
List<String> cacheProfiles,
|
||||
Map<String, Boolean> installedControls) {
|
||||
|
||||
public WebPlatformSnapshot {
|
||||
Objects.requireNonNull(transport, "transport");
|
||||
apiVersions = List.copyOf(apiVersions);
|
||||
problemCodes = List.copyOf(problemCodes);
|
||||
budgetProfiles = Map.copyOf(budgetProfiles);
|
||||
admissionProfiles = Map.copyOf(admissionProfiles);
|
||||
cacheProfiles = List.copyOf(cacheProfiles);
|
||||
installedControls = Map.copyOf(installedControls);
|
||||
}
|
||||
|
||||
/** The controls that are declared but not wired. */
|
||||
public List<String> uninstalledControls() {
|
||||
return installedControls.entrySet().stream()
|
||||
.filter(entry -> !entry.getValue())
|
||||
.map(Map.Entry::getKey)
|
||||
.sorted()
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
package dev.caskeleton.adapter.inbound.web.admin.platform;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Refuses to start when a declared control is not actually wired.
|
||||
*
|
||||
* <p>At startup, because the alternative is finding out from an incident. A control that is
|
||||
* configured and not installed behaves exactly like one that is working right up until the moment
|
||||
* it is needed — the rate limiter that never limits, the budget that never bounds, the problem
|
||||
* catalog that nothing consults. This session found the third of those by accident, through a
|
||||
* parity recording; a startup check is what finds the next one on purpose.
|
||||
*
|
||||
* <p>Fail-closed. A validator that logged a warning would be read by nobody: the deployment starts,
|
||||
* the dashboards are green, and the warning scrolls past in the first thirty seconds of a log
|
||||
* nobody keeps.
|
||||
*/
|
||||
public final class WebPlatformStartupValidator {
|
||||
|
||||
private final List<String> required;
|
||||
|
||||
/**
|
||||
* A validator over the controls this deployment claims.
|
||||
*
|
||||
* @param required the controls that must be wired
|
||||
*/
|
||||
public WebPlatformStartupValidator(List<String> required) {
|
||||
this.required = List.copyOf(Objects.requireNonNull(required, "required"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuses a snapshot that is missing a required control.
|
||||
*
|
||||
* @param snapshot what the running platform reports
|
||||
* @throws IllegalStateException naming every missing control at once
|
||||
*/
|
||||
public void validate(WebPlatformSnapshot snapshot) {
|
||||
Objects.requireNonNull(snapshot, "snapshot");
|
||||
List<String> missing = new ArrayList<>();
|
||||
for (String control : required) {
|
||||
if (!Boolean.TRUE.equals(snapshot.installedControls().get(control))) {
|
||||
missing.add(control);
|
||||
}
|
||||
}
|
||||
if (!missing.isEmpty()) {
|
||||
// Every one at once. Reporting the first sends an operator through as many restarts as there
|
||||
// are problems, and each restart is a deploy.
|
||||
throw new IllegalStateException(
|
||||
"the web platform declares controls that are not wired: "
|
||||
+ missing
|
||||
+ ". A configured-but-uninstalled control is indistinguishable from a working one"
|
||||
+ " until the moment it is needed, so startup fails here rather than in production.");
|
||||
}
|
||||
if (snapshot.problemCodes().isEmpty()) {
|
||||
throw new IllegalStateException(
|
||||
"no problem catalog is installed; every failure would be answered by the framework's"
|
||||
+ " own document, which carries no code for a client to branch on");
|
||||
}
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package dev.caskeleton.adapter.inbound.web.admin.route;
|
||||
|
||||
/**
|
||||
* The routes this deployment actually serves are not the routes it declared.
|
||||
*
|
||||
* <p>The failure the release gate is built around. A route inventory is only worth having if a
|
||||
* disagreement with the approved manifest stops the release: an endpoint that appears without a
|
||||
* review is an endpoint whose authorization, budget and idempotency policy nobody chose.
|
||||
*/
|
||||
public final class RouteInventoryMismatchException extends RuntimeException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/** Creates the failure. */
|
||||
public RouteInventoryMismatchException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
package dev.caskeleton.adapter.inbound.web.admin.route;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.web.core.ApiMajorVersion;
|
||||
import dev.caskeleton.adapter.inbound.web.core.WebOperationName;
|
||||
import dev.caskeleton.adapter.inbound.web.core.WebRouteId;
|
||||
import dev.caskeleton.adapter.inbound.web.operation.HttpMethodSemantic;
|
||||
import dev.caskeleton.adapter.inbound.web.versioning.ApiDeprecationPolicy;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import org.springframework.web.method.HandlerMethod;
|
||||
import org.springframework.web.servlet.mvc.method.RequestMappingInfo;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
|
||||
|
||||
/**
|
||||
* Reads the routes Spring MVC actually registered.
|
||||
*
|
||||
* <p>From the handler mapping rather than from annotations. The mapping is what the dispatcher will
|
||||
* consult, so it is the only source that reflects path prefixes, conditional registration and any
|
||||
* programmatic mapping — an annotation scan describes what the code says and the mapping describes
|
||||
* what the deployment serves, and the gate has to be about the second.
|
||||
*
|
||||
* <p>A mapping with no path or no method is skipped rather than guessed at: Spring permits both,
|
||||
* and inventing "GET" or "/" for them would put a route in the manifest that nobody can call.
|
||||
*/
|
||||
public final class SpringMvcRouteInventoryCollector {
|
||||
|
||||
private final ApiDeprecationPolicy deprecationPolicy;
|
||||
|
||||
/**
|
||||
* A collector that annotates routes with their deprecation state.
|
||||
*
|
||||
* @param deprecationPolicy the registered deprecations
|
||||
*/
|
||||
public SpringMvcRouteInventoryCollector(ApiDeprecationPolicy deprecationPolicy) {
|
||||
this.deprecationPolicy = Objects.requireNonNull(deprecationPolicy, "deprecationPolicy");
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects every registered route.
|
||||
*
|
||||
* @param mapping the dispatcher's handler mapping
|
||||
* @param defaultVersion the version to record for a path that carries none
|
||||
*/
|
||||
public WebRouteInventory collect(
|
||||
RequestMappingHandlerMapping mapping, ApiMajorVersion defaultVersion) {
|
||||
Objects.requireNonNull(mapping, "mapping");
|
||||
Objects.requireNonNull(defaultVersion, "defaultVersion");
|
||||
WebRouteInventory inventory = new WebRouteInventory();
|
||||
for (var entry : mapping.getHandlerMethods().entrySet()) {
|
||||
RequestMappingInfo info = entry.getKey();
|
||||
HandlerMethod handler = entry.getValue();
|
||||
Set<String> patterns = patternsOf(info);
|
||||
Set<org.springframework.web.bind.annotation.RequestMethod> methods =
|
||||
info.getMethodsCondition().getMethods();
|
||||
if (patterns.isEmpty() || methods.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
for (String pattern : patterns) {
|
||||
for (var method : methods) {
|
||||
HttpMethodSemantic semantic = semanticOf(method.name());
|
||||
if (semantic == null) {
|
||||
continue;
|
||||
}
|
||||
inventory.add(contract(info, handler, pattern, semantic, defaultVersion));
|
||||
}
|
||||
}
|
||||
}
|
||||
return inventory;
|
||||
}
|
||||
|
||||
private WebRouteContract contract(
|
||||
RequestMappingInfo info,
|
||||
HandlerMethod handler,
|
||||
String pattern,
|
||||
HttpMethodSemantic method,
|
||||
ApiMajorVersion defaultVersion) {
|
||||
String routeKey = method.name() + " " + pattern;
|
||||
var deprecation = deprecationPolicy.find(routeKey);
|
||||
return new WebRouteContract(
|
||||
new WebRouteId(routeKey),
|
||||
operationName(handler),
|
||||
versionOf(pattern, defaultVersion),
|
||||
method,
|
||||
pattern,
|
||||
List.copyOf(mediaTypes(info.getConsumesCondition().getConsumableMediaTypes())),
|
||||
List.copyOf(mediaTypes(info.getProducesCondition().getProducibleMediaTypes())),
|
||||
deprecation.isPresent(),
|
||||
deprecation.flatMap(route -> route.sunsetAt()));
|
||||
}
|
||||
|
||||
/**
|
||||
* The operation name for a handler.
|
||||
*
|
||||
* <p>Derived from the controller and method names, lower-cased and hyphen-joined, because a
|
||||
* handler that has not declared one still needs an identity the inventory can report — and the
|
||||
* catalog check will then refuse it, which is the intended outcome for an unregistered route.
|
||||
*/
|
||||
private WebOperationName operationName(HandlerMethod handler) {
|
||||
String type = handler.getBeanType().getSimpleName().replace("Controller", "");
|
||||
String raw = (type + "." + handler.getMethod().getName()).toLowerCase(java.util.Locale.ROOT);
|
||||
String normalised = raw.replaceAll("[^a-z0-9.-]", "-");
|
||||
return new WebOperationName(normalised.length() >= 3 ? normalised : "route." + normalised);
|
||||
}
|
||||
|
||||
private ApiMajorVersion versionOf(String pattern, ApiMajorVersion defaultVersion) {
|
||||
var matcher = java.util.regex.Pattern.compile("^/api/v([1-9][0-9]*)(?:/|$)").matcher(pattern);
|
||||
return matcher.find()
|
||||
? new ApiMajorVersion(Integer.parseInt(matcher.group(1)))
|
||||
: defaultVersion;
|
||||
}
|
||||
|
||||
private static Set<String> patternsOf(RequestMappingInfo info) {
|
||||
if (info.getPathPatternsCondition() != null) {
|
||||
return info.getPathPatternsCondition().getPatternValues();
|
||||
}
|
||||
return Set.of();
|
||||
}
|
||||
|
||||
private static List<String> mediaTypes(Set<org.springframework.http.MediaType> types) {
|
||||
return types.stream().map(Object::toString).sorted().toList();
|
||||
}
|
||||
|
||||
private static HttpMethodSemantic semanticOf(String name) {
|
||||
for (HttpMethodSemantic candidate : HttpMethodSemantic.values()) {
|
||||
if (candidate.name().equals(name)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The optional deprecation for a route key, exposed for a diagnostic. */
|
||||
public Optional<?> deprecationFor(String routeKey) {
|
||||
return deprecationPolicy.find(routeKey);
|
||||
}
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
package dev.caskeleton.adapter.inbound.web.admin.route;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.web.core.ApiMajorVersion;
|
||||
import dev.caskeleton.adapter.inbound.web.core.WebOperationName;
|
||||
import dev.caskeleton.adapter.inbound.web.core.WebRouteId;
|
||||
import dev.caskeleton.adapter.inbound.web.operation.HttpMethodSemantic;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* One published route, as the inventory records it.
|
||||
*
|
||||
* <p>This is the unit the release gate compares. It carries the facts a reviewer needs to decide
|
||||
* whether an endpoint should exist — what it is called, which version it belongs to, what it reads
|
||||
* and writes, and whether it is on its way out — and nothing that changes between deployments, so
|
||||
* the comparison is about the contract rather than about the environment.
|
||||
*
|
||||
* @param routeId the route identity
|
||||
* @param operationName the registered operation this route serves
|
||||
* @param apiVersion the major version it belongs to
|
||||
* @param method the HTTP method
|
||||
* @param pathTemplate the path template, never a resolved URI
|
||||
* @param consumes the media types the route reads
|
||||
* @param produces the media types the route writes
|
||||
* @param deprecated whether the route is deprecated
|
||||
* @param sunsetAt when it stops being served, when a date has been committed to
|
||||
*/
|
||||
public record WebRouteContract(
|
||||
WebRouteId routeId,
|
||||
WebOperationName operationName,
|
||||
ApiMajorVersion apiVersion,
|
||||
HttpMethodSemantic method,
|
||||
String pathTemplate,
|
||||
List<String> consumes,
|
||||
List<String> produces,
|
||||
boolean deprecated,
|
||||
Optional<Instant> sunsetAt) {
|
||||
|
||||
public WebRouteContract {
|
||||
Objects.requireNonNull(routeId, "routeId");
|
||||
Objects.requireNonNull(operationName, "operationName");
|
||||
Objects.requireNonNull(apiVersion, "apiVersion");
|
||||
Objects.requireNonNull(method, "method");
|
||||
Objects.requireNonNull(pathTemplate, "pathTemplate");
|
||||
Objects.requireNonNull(sunsetAt, "sunsetAt");
|
||||
if (!pathTemplate.startsWith("/")) {
|
||||
throw new IllegalArgumentException("route path template must be absolute: " + pathTemplate);
|
||||
}
|
||||
consumes = List.copyOf(consumes);
|
||||
produces = List.copyOf(produces);
|
||||
if (!deprecated && sunsetAt.isPresent()) {
|
||||
throw new IllegalArgumentException(
|
||||
"a route with a sunset date is deprecated by definition: " + pathTemplate);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The key two routes may not share.
|
||||
*
|
||||
* <p>Method, path and version together. Two handlers on the same triple is not a routing
|
||||
* ambiguity the framework resolves predictably — which one wins depends on registration order,
|
||||
* and registration order depends on classpath scanning.
|
||||
*/
|
||||
public String uniquenessKey() {
|
||||
return method.name() + " " + apiVersion.pathSegment() + " " + pathTemplate;
|
||||
}
|
||||
}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
package dev.caskeleton.adapter.inbound.web.admin.route;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.web.core.WebOperationName;
|
||||
import dev.caskeleton.adapter.inbound.web.operation.WebOperationCatalog;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.TreeMap;
|
||||
|
||||
/**
|
||||
* Every route this deployment serves, collected and checked.
|
||||
*
|
||||
* <p>Two rules, applied as routes arrive rather than at the end. A duplicate method-path-version is
|
||||
* refused because which handler wins depends on registration order and registration order depends
|
||||
* on classpath scanning — a routing decision nobody made. An operation name outside the catalog is
|
||||
* refused because it means an endpoint reached production with no budget, authorization or
|
||||
* idempotency policy.
|
||||
*
|
||||
* <p>The inventory is bounded and sorted so two runs of the same build produce the same manifest; a
|
||||
* release gate that compares against a manifest cannot tolerate ordering that varies with scanning.
|
||||
*/
|
||||
public final class WebRouteInventory {
|
||||
|
||||
private final Map<String, WebRouteContract> routes = new TreeMap<>();
|
||||
|
||||
/**
|
||||
* Records a route.
|
||||
*
|
||||
* @throws RouteInventoryMismatchException when the method-path-version triple is already taken
|
||||
*/
|
||||
public void add(WebRouteContract route) {
|
||||
Objects.requireNonNull(route, "route");
|
||||
WebRouteContract existing = routes.putIfAbsent(route.uniquenessKey(), route);
|
||||
if (existing != null) {
|
||||
throw new RouteInventoryMismatchException(
|
||||
"duplicate route "
|
||||
+ route.uniquenessKey()
|
||||
+ "; which handler wins would depend on classpath scanning order");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuses any route whose operation is not registered.
|
||||
*
|
||||
* @param catalog the registered operations
|
||||
* @throws RouteInventoryMismatchException naming every unregistered operation at once
|
||||
*/
|
||||
public void requireRegisteredOperations(WebOperationCatalog catalog) {
|
||||
Objects.requireNonNull(catalog, "catalog");
|
||||
List<String> unregistered = new ArrayList<>();
|
||||
for (WebRouteContract route : routes.values()) {
|
||||
WebOperationName name = route.operationName();
|
||||
try {
|
||||
catalog.require(name);
|
||||
} catch (RuntimeException unknown) {
|
||||
unregistered.add(route.uniquenessKey() + " -> " + name.value());
|
||||
}
|
||||
}
|
||||
if (!unregistered.isEmpty()) {
|
||||
throw new RouteInventoryMismatchException(
|
||||
"routes serve unregistered operations, so they have no budget, authorization or"
|
||||
+ " idempotency policy: "
|
||||
+ unregistered);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares the served routes against an approved manifest.
|
||||
*
|
||||
* @param approvedKeys the uniqueness keys the manifest declares
|
||||
* @throws RouteInventoryMismatchException when either side has something the other does not
|
||||
*/
|
||||
public void requireMatches(java.util.Set<String> approvedKeys) {
|
||||
Objects.requireNonNull(approvedKeys, "approvedKeys");
|
||||
List<String> added = new ArrayList<>(routes.keySet());
|
||||
added.removeAll(approvedKeys);
|
||||
List<String> removed = new ArrayList<>(approvedKeys);
|
||||
removed.removeAll(routes.keySet());
|
||||
if (!added.isEmpty() || !removed.isEmpty()) {
|
||||
throw new RouteInventoryMismatchException(
|
||||
"the served routes and the approved manifest disagree; added="
|
||||
+ added
|
||||
+ " removed="
|
||||
+ removed);
|
||||
}
|
||||
}
|
||||
|
||||
/** Every recorded route, in a deterministic order. */
|
||||
public Map<String, WebRouteContract> routes() {
|
||||
return Map.copyOf(new LinkedHashMap<>(routes));
|
||||
}
|
||||
|
||||
/** The uniqueness keys, for writing or comparing a manifest. */
|
||||
public java.util.Set<String> keys() {
|
||||
return java.util.Set.copyOf(routes.keySet());
|
||||
}
|
||||
|
||||
/** How many routes are served. */
|
||||
public int size() {
|
||||
return routes.size();
|
||||
}
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
package dev.caskeleton.adapter.inbound.web.admission;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Whether the service has room to run this request now.
|
||||
*
|
||||
* <p>Distinct from a quota decision in what it means and in what it is answered with. A quota is
|
||||
* about the caller and is 429; this is about the service and is 503. The permit is carried here
|
||||
* rather than returned separately so that releasing it is impossible to forget in one branch and
|
||||
* not another.
|
||||
*
|
||||
* @param admitted whether the request may run
|
||||
* @param permit what to close when it finishes, present only when admitted
|
||||
* @param retryAfter how long to wait, present only when refused
|
||||
* @param waited how long the request queued before this decision
|
||||
*/
|
||||
public record AdmissionDecision(
|
||||
boolean admitted,
|
||||
Optional<AdmissionPermit> permit,
|
||||
Optional<Duration> retryAfter,
|
||||
Duration waited) {
|
||||
|
||||
public AdmissionDecision {
|
||||
Objects.requireNonNull(permit, "permit");
|
||||
Objects.requireNonNull(retryAfter, "retryAfter");
|
||||
Objects.requireNonNull(waited, "waited");
|
||||
if (admitted != permit.isPresent()) {
|
||||
throw new IllegalArgumentException(
|
||||
"an admitted request holds a permit and a refused one does not; anything else leaks"
|
||||
+ " capacity or releases what it never took");
|
||||
}
|
||||
if (admitted == retryAfter.isPresent()) {
|
||||
throw new IllegalArgumentException("only a refused request is told when to come back");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The request may run.
|
||||
*
|
||||
* @param permit what to close when it finishes
|
||||
* @param waited how long it queued
|
||||
*/
|
||||
public static AdmissionDecision admitted(AdmissionPermit permit, Duration waited) {
|
||||
return new AdmissionDecision(true, Optional.of(permit), Optional.empty(), waited);
|
||||
}
|
||||
|
||||
/**
|
||||
* The service has no room.
|
||||
*
|
||||
* @param retryAfter how long to wait
|
||||
* @param waited how long it queued before being turned away
|
||||
*/
|
||||
public static AdmissionDecision refused(Duration retryAfter, Duration waited) {
|
||||
return new AdmissionDecision(false, Optional.empty(), Optional.of(retryAfter), waited);
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package dev.caskeleton.adapter.inbound.web.admission;
|
||||
|
||||
/**
|
||||
* The right to occupy one execution slot, given up when closed.
|
||||
*
|
||||
* <p>{@link AutoCloseable} so the release is a {@code try}-with-resources rather than a {@code
|
||||
* finally} somebody has to remember. A permit that is not released is capacity the service never
|
||||
* gets back, and the symptom — throughput decaying towards zero over hours — is one of the harder
|
||||
* ones to attribute.
|
||||
*
|
||||
* <p>{@code close} is idempotent in every implementation here. A double release would return
|
||||
* capacity that was never taken, which is the same bug in the opposite direction and admits more
|
||||
* concurrent work than the profile allows.
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface AdmissionPermit extends AutoCloseable {
|
||||
|
||||
/** Gives the slot back. Calling twice releases nothing extra. */
|
||||
@Override
|
||||
void close();
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
package dev.caskeleton.adapter.inbound.web.admission;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.web.operation.AdmissionProfileName;
|
||||
import java.time.Duration;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* How much of the service one class of work may occupy.
|
||||
*
|
||||
* <p>Separate profiles for separate work is the point. A single global limit sized for cheap reads
|
||||
* lets a burst of expensive writes fill it and starve everything; sized for the writes, it is no
|
||||
* limit at all for the reads. The design asks for at least a write profile and an expensive-query
|
||||
* profile, and the factories below are those.
|
||||
*
|
||||
* @param name the profile's identity
|
||||
* @param maxConcurrent how many may run at once
|
||||
* @param maxQueued how many may wait
|
||||
* @param maxQueueWait how long one may wait before being turned away
|
||||
*/
|
||||
public record AdmissionProfile(
|
||||
AdmissionProfileName name, int maxConcurrent, int maxQueued, Duration maxQueueWait) {
|
||||
|
||||
public AdmissionProfile {
|
||||
Objects.requireNonNull(name, "name");
|
||||
Objects.requireNonNull(maxQueueWait, "maxQueueWait");
|
||||
if (maxConcurrent <= 0) {
|
||||
throw new IllegalArgumentException("a profile that admits nothing is not a profile");
|
||||
}
|
||||
if (maxQueued < 0) {
|
||||
throw new IllegalArgumentException("a negative queue is not a queue");
|
||||
}
|
||||
// An unbounded queue is the failure this whole mechanism exists to prevent. It converts an
|
||||
// overload into unbounded latency and memory growth: every caller is accepted, none is served
|
||||
// in time, and the ones still waiting have long since given up and retried.
|
||||
if (maxQueued > 10 * maxConcurrent) {
|
||||
throw new IllegalArgumentException(
|
||||
"a queue of "
|
||||
+ maxQueued
|
||||
+ " against "
|
||||
+ maxConcurrent
|
||||
+ " concurrent is effectively unbounded: everything is accepted, nothing is served"
|
||||
+ " in time, and the far end has already retried");
|
||||
}
|
||||
if (maxQueueWait.isNegative()) {
|
||||
throw new IllegalArgumentException("a negative queue wait is not a wait");
|
||||
}
|
||||
if (maxQueueWait.compareTo(Duration.ofSeconds(10)) > 0) {
|
||||
throw new IllegalArgumentException(
|
||||
"a queue wait over 10s outlives the client that is waiting; refusing quickly is the"
|
||||
+ " kinder answer and the one that sheds load");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Global writes: few at once, a short queue, a short wait.
|
||||
*
|
||||
* <p>Writes contend on the same rows and the same connections, so more concurrency past a point
|
||||
* buys nothing and costs lock contention.
|
||||
*/
|
||||
public static AdmissionProfile globalWrite() {
|
||||
return new AdmissionProfile(
|
||||
new AdmissionProfileName("global-write"), 32, 64, Duration.ofMillis(250));
|
||||
}
|
||||
|
||||
/**
|
||||
* Expensive queries: fewer still, and turned away rather than queued for long.
|
||||
*
|
||||
* <p>An expensive query that has been waiting is worse than one refused: by the time it runs the
|
||||
* caller has usually gone, and the work is spent on a response nobody reads.
|
||||
*/
|
||||
public static AdmissionProfile expensiveQuery() {
|
||||
return new AdmissionProfile(
|
||||
new AdmissionProfileName("expensive-query"), 8, 8, Duration.ofMillis(100));
|
||||
}
|
||||
|
||||
/** Ordinary reads: generous, because they are cheap and mostly bounded by IO. */
|
||||
public static AdmissionProfile standard() {
|
||||
return new AdmissionProfile(AdmissionProfileName.standard(), 256, 256, Duration.ofMillis(500));
|
||||
}
|
||||
}
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
package dev.caskeleton.adapter.inbound.web.admission;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.web.operation.AdmissionProfileName;
|
||||
import java.time.Duration;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.Semaphore;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
/**
|
||||
* Bounded concurrency with a bounded queue, per profile.
|
||||
*
|
||||
* <p>A {@link Semaphore} per profile rather than one shared pool, so a saturated write profile
|
||||
* cannot refuse a read. The queue is a separate counter rather than the semaphore's own waiter
|
||||
* list, because the semaphore's list is unbounded: without the counter a spike parks every request
|
||||
* thread on {@code tryAcquire} and the bounded-queue requirement is satisfied only on paper.
|
||||
*
|
||||
* <p>The two bounds do different jobs. Concurrency decides how much work runs; the queue decides
|
||||
* how much hope is kept. A queue longer than the clients' own timeouts is worse than no queue —
|
||||
* every slot is filled with a request whose caller has already given up and retried, so the service
|
||||
* does the work twice and answers neither in time.
|
||||
*/
|
||||
public final class SemaphoreAdmissionController implements WebAdmissionController {
|
||||
|
||||
private record Gate(AdmissionProfile profile, Semaphore permits, AtomicInteger queued) {}
|
||||
|
||||
private final Map<AdmissionProfileName, Gate> gates = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* A controller over the given profiles.
|
||||
*
|
||||
* @param profiles what may run, and how much of it
|
||||
*/
|
||||
public SemaphoreAdmissionController(AdmissionProfile... profiles) {
|
||||
Objects.requireNonNull(profiles, "profiles");
|
||||
for (AdmissionProfile profile : profiles) {
|
||||
// Fair, so a request that has been queued longest goes next. Unfair acquisition is faster
|
||||
// and starves the unlucky: under sustained load a few requests wait past every timeout while
|
||||
// arrivals behind them are served, and the latency tail stops resembling the median at all.
|
||||
gates.put(
|
||||
profile.name(),
|
||||
new Gate(profile, new Semaphore(profile.maxConcurrent(), true), new AtomicInteger()));
|
||||
}
|
||||
}
|
||||
|
||||
/** A controller over the three standard profiles. */
|
||||
public static SemaphoreAdmissionController standard() {
|
||||
return new SemaphoreAdmissionController(
|
||||
AdmissionProfile.standard(),
|
||||
AdmissionProfile.globalWrite(),
|
||||
AdmissionProfile.expensiveQuery());
|
||||
}
|
||||
|
||||
@Override
|
||||
public AdmissionDecision admit(AdmissionProfileName profileName) {
|
||||
Objects.requireNonNull(profileName, "profileName");
|
||||
Gate gate = gates.get(profileName);
|
||||
if (gate == null) {
|
||||
throw new IllegalArgumentException(
|
||||
"no admission profile named "
|
||||
+ profileName
|
||||
+ "; an operation whose profile is unregistered would run with no bound at all");
|
||||
}
|
||||
|
||||
// Fast path: a free slot is taken without ever joining the queue, so an idle service adds no
|
||||
// queue accounting to the common case.
|
||||
if (gate.permits().tryAcquire()) {
|
||||
return AdmissionDecision.admitted(permitFor(gate), Duration.ZERO);
|
||||
}
|
||||
|
||||
int queueDepth = gate.queued().incrementAndGet();
|
||||
try {
|
||||
if (queueDepth > gate.profile().maxQueued()) {
|
||||
return AdmissionDecision.refused(retryAfterFor(gate), Duration.ZERO);
|
||||
}
|
||||
long startedAt = System.nanoTime();
|
||||
boolean acquired =
|
||||
gate.permits().tryAcquire(gate.profile().maxQueueWait().toNanos(), TimeUnit.NANOSECONDS);
|
||||
Duration waited = Duration.ofNanos(System.nanoTime() - startedAt);
|
||||
return acquired
|
||||
? AdmissionDecision.admitted(permitFor(gate), waited)
|
||||
: AdmissionDecision.refused(retryAfterFor(gate), waited);
|
||||
} catch (InterruptedException interrupted) {
|
||||
Thread.currentThread().interrupt();
|
||||
return AdmissionDecision.refused(retryAfterFor(gate), Duration.ZERO);
|
||||
} finally {
|
||||
gate.queued().decrementAndGet();
|
||||
}
|
||||
}
|
||||
|
||||
/** How many slots the profile currently has free. */
|
||||
public int availablePermits(AdmissionProfileName profileName) {
|
||||
Gate gate = gates.get(profileName);
|
||||
return gate == null ? 0 : gate.permits().availablePermits();
|
||||
}
|
||||
|
||||
private static AdmissionPermit permitFor(Gate gate) {
|
||||
AtomicBoolean released = new AtomicBoolean();
|
||||
return () -> {
|
||||
// Guarded, because a double release hands back a slot that was never taken and quietly
|
||||
// raises the concurrency bound above what the profile allows.
|
||||
if (released.compareAndSet(false, true)) {
|
||||
gate.permits().release();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static Duration retryAfterFor(Gate gate) {
|
||||
// The queue wait, not a fixed second: it is the timescale on which this profile actually
|
||||
// drains, so it is the honest answer to "when should I come back".
|
||||
return gate.profile().maxQueueWait().isZero()
|
||||
? Duration.ofMillis(100)
|
||||
: gate.profile().maxQueueWait();
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package dev.caskeleton.adapter.inbound.web.admission;
|
||||
|
||||
import dev.caskeleton.adapter.inbound.web.operation.AdmissionProfileName;
|
||||
|
||||
/**
|
||||
* Decides whether the service has room to run a request now.
|
||||
*
|
||||
* <p>Load shedding, not rate limiting. This looks only at what the service is currently doing and
|
||||
* knows nothing about who is calling; the limiter looks only at the caller and knows nothing about
|
||||
* the service's state. Keeping them apart is what lets a well-behaved caller be told 503 during a
|
||||
* spike and an abusive one be told 429 while the service is idle.
|
||||
*/
|
||||
public interface WebAdmissionController {
|
||||
|
||||
/**
|
||||
* Asks for a slot, waiting up to the profile's queue budget.
|
||||
*
|
||||
* @param profile which class of work this is
|
||||
*/
|
||||
AdmissionDecision admit(AdmissionProfileName profile);
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
package dev.caskeleton.adapter.inbound.web.advanced;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* The web capabilities that are not part of Stable, each behind its own flag.
|
||||
*
|
||||
* <p>One flag per capability, not one for "advanced". They have nothing in common operationally:
|
||||
* virtual threads change how every request is scheduled, streaming changes how long a response
|
||||
* holds a connection, XML adds a parser with a decades-long history of entity-expansion attacks. A
|
||||
* single switch would make those one decision, and a deployment that wanted the first would be
|
||||
* given the third.
|
||||
*
|
||||
* <p>Every constant is off unless named.
|
||||
*/
|
||||
public enum WebAdvancedFeature {
|
||||
|
||||
/**
|
||||
* A virtual-thread executor for MVC request handling.
|
||||
*
|
||||
* <p>Changes the scheduling model, not the concurrency budget. See {@code
|
||||
* VirtualThreadAdmissionGuard} for why those are different things.
|
||||
*/
|
||||
MVC_VIRTUAL_THREADS,
|
||||
|
||||
/** A bounded, registered offload for blocking work called from WebFlux. */
|
||||
WEBFLUX_BLOCKING_BRIDGE,
|
||||
|
||||
/** RFC 7396 {@code application/merge-patch+json}. */
|
||||
JSON_MERGE_PATCH,
|
||||
|
||||
/** RFC 6902 {@code application/json-patch+json}. */
|
||||
JSON_PATCH,
|
||||
|
||||
/** Server-sent events. */
|
||||
SSE,
|
||||
|
||||
/** {@code application/x-ndjson} streaming. */
|
||||
NDJSON,
|
||||
|
||||
/** RFC 7464 {@code application/json-seq} streaming. */
|
||||
JSON_SEQUENCE,
|
||||
|
||||
/** Functional WebFlux routes, registered against the operation catalog. */
|
||||
FUNCTIONAL_WEBFLUX,
|
||||
|
||||
/** {@code application/cbor} as a representation. */
|
||||
CBOR,
|
||||
|
||||
/** {@code application/xml} as a representation. */
|
||||
XML,
|
||||
|
||||
/** OpenAPI 3.2 generated alongside the Stable 3.1 snapshot. */
|
||||
OPENAPI_32,
|
||||
|
||||
/** Draft {@code RateLimit} and {@code RateLimit-Policy} response headers. */
|
||||
RATELIMIT_DRAFT_HEADERS;
|
||||
|
||||
/** The property that turns this on. */
|
||||
public String propertyName() {
|
||||
return "backend.web.advanced." + name().toLowerCase(Locale.ROOT).replace('_', '-') + ".enabled";
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether enabling this changes behaviour for requests that do not use it.
|
||||
*
|
||||
* <p>The distinction that decides how much soak a capability needs. A codec only affects requests
|
||||
* that negotiate it; a virtual-thread executor affects every request in the process, and the
|
||||
* blocking bridge affects the event loop that every reactive request shares.
|
||||
*/
|
||||
public boolean affectsUnrelatedRequests() {
|
||||
return this == MVC_VIRTUAL_THREADS || this == WEBFLUX_BLOCKING_BRIDGE;
|
||||
}
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
package dev.caskeleton.adapter.inbound.web.advanced;
|
||||
|
||||
import java.util.EnumSet;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Which Advanced capabilities this deployment named.
|
||||
*
|
||||
* <p>A value rather than a property lookup at each call site, so that "what is on" is one thing an
|
||||
* operator can print rather than a set of conditions scattered through the configuration.
|
||||
*/
|
||||
public final class WebAdvancedFeatureFlags {
|
||||
|
||||
private final Set<WebAdvancedFeature> enabled;
|
||||
|
||||
private WebAdvancedFeatureFlags(Set<WebAdvancedFeature> enabled) {
|
||||
this.enabled = Set.copyOf(enabled);
|
||||
}
|
||||
|
||||
/** Nothing enabled. The default, and what a Stable deployment has. */
|
||||
public static WebAdvancedFeatureFlags none() {
|
||||
return new WebAdvancedFeatureFlags(EnumSet.noneOf(WebAdvancedFeature.class));
|
||||
}
|
||||
|
||||
/** Exactly these. */
|
||||
public static WebAdvancedFeatureFlags of(WebAdvancedFeature... features) {
|
||||
Objects.requireNonNull(features, "features");
|
||||
return new WebAdvancedFeatureFlags(Set.of(features));
|
||||
}
|
||||
|
||||
/** Whether a capability is on. */
|
||||
public boolean enabled(WebAdvancedFeature feature) {
|
||||
Objects.requireNonNull(feature, "feature");
|
||||
return enabled.contains(feature);
|
||||
}
|
||||
|
||||
/** Everything that is on, for the startup report. */
|
||||
public Set<WebAdvancedFeature> all() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
/** Whether this deployment behaves as a Stable one for requests that use no Advanced feature. */
|
||||
public boolean stableBehaviourPreserved() {
|
||||
return enabled.stream().noneMatch(WebAdvancedFeature::affectsUnrelatedRequests);
|
||||
}
|
||||
}
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
package dev.caskeleton.adapter.inbound.web.advanced.blockingbridge;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.Semaphore;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.LongAdder;
|
||||
|
||||
/**
|
||||
* The bounded pool the bridge offloads into, and what it observed.
|
||||
*
|
||||
* <p>Separate from the bridge itself so the accounting can be asserted without a Reactor pipeline.
|
||||
* The number that matters — the peak concurrency actually reached — is invisible from throughput
|
||||
* and from latency; a bridge whose bound is not applied looks exactly like one whose bound is
|
||||
* generous, right up until the pool is the heap.
|
||||
*/
|
||||
public final class BlockingBridgeBudget {
|
||||
|
||||
private static final String UNREGISTERED = "<unregistered>";
|
||||
|
||||
private final BlockingBridgeProfile profile;
|
||||
private final Semaphore permits;
|
||||
private final AtomicInteger inFlight = new AtomicInteger();
|
||||
private final AtomicInteger peakConcurrency = new AtomicInteger();
|
||||
private final Map<String, LongAdder> rejectionsByOperation = new ConcurrentHashMap<>();
|
||||
|
||||
public BlockingBridgeBudget(BlockingBridgeProfile profile) {
|
||||
this.profile = Objects.requireNonNull(profile, "profile");
|
||||
this.permits = new Semaphore(profile.maxConcurrency(), true);
|
||||
}
|
||||
|
||||
/** The profile. */
|
||||
public BlockingBridgeProfile profile() {
|
||||
return profile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Take a slot, or refuse.
|
||||
*
|
||||
* @param operation which registered operation
|
||||
* @throws BlockingBridgeRejectedException if unregistered or no slot became free in time
|
||||
*/
|
||||
public void acquire(String operation) throws InterruptedException {
|
||||
Objects.requireNonNull(operation, "operation");
|
||||
if (!profile.registered(operation)) {
|
||||
record(operation);
|
||||
throw new BlockingBridgeRejectedException(
|
||||
operation, BlockingBridgeRejectedException.Reason.NOT_REGISTERED);
|
||||
}
|
||||
Duration timeout = profile.queueTimeout();
|
||||
if (!permits.tryAcquire(timeout.toMillis(), TimeUnit.MILLISECONDS)) {
|
||||
record(operation);
|
||||
throw new BlockingBridgeRejectedException(
|
||||
operation, BlockingBridgeRejectedException.Reason.QUEUE_TIMEOUT);
|
||||
}
|
||||
int current = inFlight.incrementAndGet();
|
||||
peakConcurrency.accumulateAndGet(current, Math::max);
|
||||
}
|
||||
|
||||
/** Give the slot back. */
|
||||
public void release() {
|
||||
inFlight.decrementAndGet();
|
||||
permits.release();
|
||||
}
|
||||
|
||||
/** How many are running now. */
|
||||
public int inFlight() {
|
||||
return inFlight.get();
|
||||
}
|
||||
|
||||
/** The most that ever ran at once. Must never exceed the configured bound. */
|
||||
public int peakConcurrency() {
|
||||
return peakConcurrency.get();
|
||||
}
|
||||
|
||||
/** How many offloads this operation had refused. */
|
||||
public long rejectionsFor(String operation) {
|
||||
LongAdder counter = rejectionsByOperation.get(operation);
|
||||
return counter == null ? 0L : counter.sum();
|
||||
}
|
||||
|
||||
/** Refusals of operations nobody registered, under one key rather than one key each. */
|
||||
public long unregisteredRejections() {
|
||||
return rejectionsFor(UNREGISTERED);
|
||||
}
|
||||
|
||||
private void record(String operation) {
|
||||
// Only registered names become keys. An unregistered one is counted under a single sentinel,
|
||||
// because the caller supplies that string and a map keyed on it grows with whatever is passed
|
||||
// — which is the same unbounded-cardinality problem a metric tagged with client input has.
|
||||
String key = profile.registered(operation) ? operation : UNREGISTERED;
|
||||
rejectionsByOperation.computeIfAbsent(key, ignored -> new LongAdder()).increment();
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package dev.caskeleton.adapter.inbound.web.advanced.blockingbridge;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Which blocking operations may be offloaded, and how much of the offload pool each may use.
|
||||
*
|
||||
* <p>The registration is the point. Reactor's {@code boundedElastic()} is available from anywhere
|
||||
* and unbounded in practice — it grows to ten times the CPU count *per scheduler* and queues
|
||||
* without limit beyond that — so a controller that calls it has silently opted the whole
|
||||
* application into an unbounded thread pool. Every such call site is invisible until the pool is
|
||||
* the thing consuming the heap.
|
||||
*
|
||||
* <p>A registered set makes the offloads enumerable. The concurrency bound makes them survivable:
|
||||
* without it, a slow dependency's callers accumulate on the pool and starve the fast ones, which
|
||||
* looks like the fast dependency having failed.
|
||||
*
|
||||
* @param registeredOperations the operations permitted to offload
|
||||
* @param maxConcurrency how many may run at once
|
||||
* @param queueTimeout how long a caller waits for a slot before being refused
|
||||
*/
|
||||
public record BlockingBridgeProfile(
|
||||
Set<String> registeredOperations, int maxConcurrency, Duration queueTimeout) {
|
||||
|
||||
public BlockingBridgeProfile {
|
||||
registeredOperations =
|
||||
Set.copyOf(Objects.requireNonNull(registeredOperations, "registeredOperations"));
|
||||
Objects.requireNonNull(queueTimeout, "queueTimeout");
|
||||
if (registeredOperations.isEmpty()) {
|
||||
throw new IllegalArgumentException(
|
||||
"a bridge with no registered operation refuses everything; if nothing blocks, do not "
|
||||
+ "enable the bridge");
|
||||
}
|
||||
if (maxConcurrency < 1) {
|
||||
throw new IllegalArgumentException("an offload pool of zero runs nothing");
|
||||
}
|
||||
if (queueTimeout.isNegative() || queueTimeout.isZero()) {
|
||||
throw new IllegalArgumentException(
|
||||
"an unbounded queue wait means a slow dependency's callers accumulate until the heap "
|
||||
+ "does, and the fast dependencies starve behind them");
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether an operation may use the bridge. */
|
||||
public boolean registered(String operation) {
|
||||
return operation != null && registeredOperations.contains(operation);
|
||||
}
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
package dev.caskeleton.adapter.inbound.web.advanced.blockingbridge;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* A blocking offload was refused.
|
||||
*
|
||||
* <p>Two reasons, kept apart because they mean different things to whoever is looking. An
|
||||
* unregistered operation is a programming error found at runtime — somebody called the bridge from
|
||||
* a path nobody declared. A full pool is a capacity signal.
|
||||
*/
|
||||
public final class BlockingBridgeRejectedException extends RuntimeException {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private final transient String operation;
|
||||
private final transient Reason reason;
|
||||
|
||||
public BlockingBridgeRejectedException(String operation, Reason reason) {
|
||||
super("blocking offload refused for '" + operation + "': " + reason);
|
||||
this.operation = Objects.requireNonNull(operation, "operation");
|
||||
this.reason = Objects.requireNonNull(reason, "reason");
|
||||
}
|
||||
|
||||
/** Which operation. */
|
||||
public String operation() {
|
||||
return operation;
|
||||
}
|
||||
|
||||
/** Why. */
|
||||
public Reason reason() {
|
||||
return reason;
|
||||
}
|
||||
|
||||
/** Why an offload was refused. */
|
||||
public enum Reason {
|
||||
|
||||
/** Nobody declared this operation. A bug, not a capacity signal. */
|
||||
NOT_REGISTERED,
|
||||
|
||||
/** The bounded pool is full. */
|
||||
CAPACITY_EXHAUSTED,
|
||||
|
||||
/** No slot became available within the queue timeout. */
|
||||
QUEUE_TIMEOUT
|
||||
}
|
||||
}
|
||||
+61
@@ -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;
|
||||
}
|
||||
}
|
||||
+84
@@ -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();
|
||||
}
|
||||
}
|
||||
+59
@@ -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"));
|
||||
}
|
||||
}
|
||||
+87
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+90
@@ -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();
|
||||
}
|
||||
}
|
||||
+99
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+53
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+60
@@ -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();
|
||||
}
|
||||
}
|
||||
+45
@@ -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;
|
||||
}
|
||||
}
|
||||
+102
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
+86
@@ -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");
|
||||
}
|
||||
}
|
||||
+117
@@ -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();
|
||||
}
|
||||
}
|
||||
+86
@@ -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);
|
||||
}
|
||||
}
|
||||
+77
@@ -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;
|
||||
}
|
||||
}
|
||||
+103
@@ -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());
|
||||
}
|
||||
}
|
||||
+72
@@ -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();
|
||||
}
|
||||
}
|
||||
+40
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+81
@@ -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
|
||||
}
|
||||
}
|
||||
+36
@@ -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;
|
||||
}
|
||||
}
|
||||
+122
@@ -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();
|
||||
}
|
||||
}
|
||||
+87
@@ -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;
|
||||
}
|
||||
}
|
||||
+81
@@ -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;
|
||||
}
|
||||
}
|
||||
+190
@@ -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", "~");
|
||||
}
|
||||
}
|
||||
+90
@@ -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));
|
||||
}
|
||||
}
|
||||
+71
@@ -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));
|
||||
}
|
||||
}
|
||||
+83
@@ -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();
|
||||
}
|
||||
}
|
||||
+11
@@ -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);
|
||||
}
|
||||
}
|
||||
+31
@@ -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;
|
||||
}
|
||||
}
|
||||
+95
@@ -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();
|
||||
}
|
||||
}
|
||||
+27
@@ -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;
|
||||
}
|
||||
}
|
||||
+69
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+23
@@ -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() {}
|
||||
}
|
||||
+28
@@ -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();
|
||||
}
|
||||
}
|
||||
+73
@@ -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);
|
||||
}
|
||||
}
|
||||
+36
@@ -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);
|
||||
}
|
||||
}
|
||||
+41
@@ -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;
|
||||
}
|
||||
}
|
||||
+138
@@ -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();
|
||||
}
|
||||
}
|
||||
+90
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+27
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+35
@@ -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);
|
||||
}
|
||||
}
|
||||
+43
@@ -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);
|
||||
}
|
||||
}
|
||||
+41
@@ -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;
|
||||
}
|
||||
}
|
||||
+83
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+68
@@ -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>;
|
||||
}
|
||||
}
|
||||
+58
@@ -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();
|
||||
}
|
||||
}
|
||||
+135
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+74
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+92
@@ -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();
|
||||
}
|
||||
}
|
||||
+34
@@ -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();
|
||||
}
|
||||
+45
@@ -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;
|
||||
}
|
||||
}
|
||||
+53
@@ -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");
|
||||
}
|
||||
}
|
||||
+68
@@ -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);
|
||||
}
|
||||
}
|
||||
+56
@@ -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();
|
||||
}
|
||||
}
|
||||
+61
@@ -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>;
|
||||
}
|
||||
}
|
||||
+34
@@ -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);
|
||||
}
|
||||
}
|
||||
+33
@@ -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
Reference in New Issue
Block a user